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/service.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
2
3
|
import { lstat, open, readFile, unlink } from "node:fs/promises";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
5
7
|
|
|
6
8
|
import {
|
|
7
9
|
bootstrapServer,
|
|
@@ -25,6 +27,11 @@ import {
|
|
|
25
27
|
type RunningServer,
|
|
26
28
|
} from "./https-server.js";
|
|
27
29
|
import { resolveBindOptions } from "./network-policy.js";
|
|
30
|
+
import { DEFAULT_PORT } from "./network-policy.js";
|
|
31
|
+
import {
|
|
32
|
+
readPortableServerCredential,
|
|
33
|
+
writePortableServerCredential,
|
|
34
|
+
} from "./portable-credential-store.js";
|
|
28
35
|
import {
|
|
29
36
|
invitationCubeAmbiguousError,
|
|
30
37
|
operatorErrors,
|
|
@@ -41,10 +48,22 @@ import {
|
|
|
41
48
|
type StorageLimits,
|
|
42
49
|
} from "./store.js";
|
|
43
50
|
import type { CubeAccess } from "./store.js";
|
|
51
|
+
import { fileURLToPath } from "node:url";
|
|
52
|
+
import { loadRuntimeBuildIdentity, type RuntimeBuildIdentity } from "./runtime-identity.js";
|
|
53
|
+
import {
|
|
54
|
+
createRuntimeLifecycle,
|
|
55
|
+
createUnixNpmArtifactUnpacker,
|
|
56
|
+
inspectActiveRuntimeArtifact,
|
|
57
|
+
} from "./runtime-lifecycle.js";
|
|
58
|
+
import { createRegistryArtifactSource } from "./registry-artifact.js";
|
|
59
|
+
import { createRuntimeOperator, type RuntimeUpdateResult } from "./runtime-operator.js";
|
|
60
|
+
import { createManagedServiceDefinition } from "./managed-service.js";
|
|
44
61
|
|
|
45
62
|
export interface ServerService {
|
|
46
63
|
readonly start: (args: readonly string[]) => Promise<void>;
|
|
47
|
-
readonly setup?: (options: SetupOptions) => Promise<
|
|
64
|
+
readonly setup?: (options: SetupOptions) => Promise<ServerSetupResult>;
|
|
65
|
+
readonly status?: () => Promise<ServerRuntimeStatus>;
|
|
66
|
+
readonly update?: () => Promise<RuntimeUpdateResult>;
|
|
48
67
|
readonly rotateClient?: (clientId: string) => Promise<string>;
|
|
49
68
|
readonly revokeClient?: (clientId: string) => Promise<void>;
|
|
50
69
|
readonly grantClient?: (clientId: string, cubeId: string, access: CubeAccess) => Promise<void>;
|
|
@@ -55,12 +74,31 @@ export interface ServerService {
|
|
|
55
74
|
access?: CubeAccess,
|
|
56
75
|
) => Promise<string | CubeInvitationResult>;
|
|
57
76
|
readonly replaceOwnerInvitation?: (recoveryCredential: string) => Promise<string>;
|
|
77
|
+
readonly invite?: () => Promise<string>;
|
|
58
78
|
}
|
|
59
79
|
|
|
60
80
|
export interface SetupOptions {
|
|
61
81
|
readonly reinitialize: boolean;
|
|
62
82
|
}
|
|
63
83
|
|
|
84
|
+
export type ServerSetupResult =
|
|
85
|
+
| (Omit<BootstrapResult, "recoveryCredential" | "initialInvitation"> & {
|
|
86
|
+
readonly artifact?: { readonly version: string; readonly integrity: string; readonly sourceSha: string | null };
|
|
87
|
+
})
|
|
88
|
+
| {
|
|
89
|
+
readonly existing: true;
|
|
90
|
+
readonly artifact?: { readonly version: string; readonly integrity: string; readonly sourceSha: string | null };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export interface ServerRuntimeStatus {
|
|
94
|
+
readonly status: "running" | "stopped";
|
|
95
|
+
readonly artifact: { readonly version: string; readonly integrity: string } | null;
|
|
96
|
+
readonly buildIdentity: string | null;
|
|
97
|
+
readonly endpoint: string | null;
|
|
98
|
+
readonly mode: "foreground" | "managed" | "stopped";
|
|
99
|
+
readonly dataIdentity: "available" | "unavailable";
|
|
100
|
+
}
|
|
101
|
+
|
|
64
102
|
export interface ServerEnvironment {
|
|
65
103
|
readonly BORG_SERVER_TLS_KEY_FILE?: string;
|
|
66
104
|
readonly BORG_SERVER_TLS_CERT_FILE?: string;
|
|
@@ -70,6 +108,10 @@ export interface ServerEnvironment {
|
|
|
70
108
|
readonly BORG_SERVER_MAX_ACTIVITY_ENTRIES_PER_CUBE?: string;
|
|
71
109
|
readonly BORG_SERVER_MAX_DATABASE_BYTES?: string;
|
|
72
110
|
readonly BORG_SERVER_MIN_FREE_DISK_BYTES?: string;
|
|
111
|
+
readonly BORG_SERVER_SOURCE_SHA?: string;
|
|
112
|
+
readonly BORG_SERVER_ARTIFACT_INTEGRITY?: string;
|
|
113
|
+
readonly BORG_SERVER_PROCESS_MODE?: "foreground" | "managed";
|
|
114
|
+
readonly BORG_SERVER_RUNTIME_DIR?: string;
|
|
73
115
|
}
|
|
74
116
|
|
|
75
117
|
interface ServiceDependencies {
|
|
@@ -77,7 +119,7 @@ interface ServiceDependencies {
|
|
|
77
119
|
readonly readFile: (path: string) => Promise<Buffer>;
|
|
78
120
|
readonly readPrivateKey: (path: string) => Promise<Buffer>;
|
|
79
121
|
readonly startServer: (options: HttpsServerOptions) => Promise<RunningServer>;
|
|
80
|
-
readonly onStarted: (origin: string) => void;
|
|
122
|
+
readonly onStarted: (origin: string, identity: RuntimeBuildIdentity) => void;
|
|
81
123
|
readonly waitForShutdown: (server: RunningServer, signal?: AbortSignal) => Promise<void>;
|
|
82
124
|
readonly debugOutput?: (line: string) => void;
|
|
83
125
|
readonly installShutdownHandlers?: () => { readonly signal: AbortSignal; readonly dispose: () => void };
|
|
@@ -98,6 +140,16 @@ interface RuntimeResources {
|
|
|
98
140
|
readonly livenessScheduler: { readonly stop: () => void } | undefined;
|
|
99
141
|
}
|
|
100
142
|
|
|
143
|
+
export type RuntimeLockStatus =
|
|
144
|
+
| { readonly running: false }
|
|
145
|
+
| {
|
|
146
|
+
readonly running: true;
|
|
147
|
+
readonly pid: number;
|
|
148
|
+
readonly identity: RuntimeBuildIdentity | null;
|
|
149
|
+
readonly endpoint: string | null;
|
|
150
|
+
readonly mode: "foreground" | "managed";
|
|
151
|
+
};
|
|
152
|
+
|
|
101
153
|
const guardedRuntimeFailures = new Set<RuntimeResources>();
|
|
102
154
|
|
|
103
155
|
export interface NodeServerTestHooks {
|
|
@@ -163,8 +215,25 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
|
|
|
163
215
|
}
|
|
164
216
|
|
|
165
217
|
let runtimeLock: Awaited<ReturnType<typeof acquireRuntimeLock>> | undefined;
|
|
218
|
+
let runtimeIdentity: RuntimeBuildIdentity;
|
|
166
219
|
try {
|
|
167
|
-
|
|
220
|
+
runtimeIdentity = await loadRuntimeBuildIdentity({
|
|
221
|
+
...(dependencies.environment.BORG_SERVER_SOURCE_SHA === undefined
|
|
222
|
+
? {}
|
|
223
|
+
: { sourceSha: dependencies.environment.BORG_SERVER_SOURCE_SHA }),
|
|
224
|
+
...(dependencies.environment.BORG_SERVER_ARTIFACT_INTEGRITY === undefined
|
|
225
|
+
? {}
|
|
226
|
+
: { artifactIntegrity: dependencies.environment.BORG_SERVER_ARTIFACT_INTEGRITY }),
|
|
227
|
+
artifactDescriptorPath: fileURLToPath(new URL("../../artifact.json", import.meta.url)),
|
|
228
|
+
});
|
|
229
|
+
runtimeLock = dataDirectory === undefined
|
|
230
|
+
? undefined
|
|
231
|
+
: await acquireRuntimeLock(
|
|
232
|
+
dataDirectory,
|
|
233
|
+
"server",
|
|
234
|
+
runtimeIdentity,
|
|
235
|
+
dependencies.environment.BORG_SERVER_PROCESS_MODE ?? "foreground",
|
|
236
|
+
);
|
|
168
237
|
await dependencies.onStartupPhase?.("post-lock");
|
|
169
238
|
throwIfShutdown(shutdown?.signal);
|
|
170
239
|
} catch (error) {
|
|
@@ -239,6 +308,7 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
|
|
|
239
308
|
? {}
|
|
240
309
|
: { handleCoordination: (request) => coordinationApi.handle(request) }),
|
|
241
310
|
debugLogger,
|
|
311
|
+
runtimeIdentity,
|
|
242
312
|
});
|
|
243
313
|
throwIfShutdown(shutdown?.signal);
|
|
244
314
|
if (authRuntime !== undefined) {
|
|
@@ -263,7 +333,8 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
|
|
|
263
333
|
let failure: unknown;
|
|
264
334
|
try {
|
|
265
335
|
throwIfShutdown(shutdown?.signal);
|
|
266
|
-
|
|
336
|
+
await runtimeLock?.updateOrigin?.(running.origin);
|
|
337
|
+
dependencies.onStarted(running.origin, runtimeIdentity);
|
|
267
338
|
debugLogger.emit({ event: "lifecycle", action: "listening" });
|
|
268
339
|
await dependencies.waitForShutdown(running, shutdown?.signal);
|
|
269
340
|
} catch (error) {
|
|
@@ -303,6 +374,13 @@ export function selectServerEnvironment(environment: NodeJS.ProcessEnv): ServerE
|
|
|
303
374
|
const maxActivityEntries = environment["BORG_SERVER_MAX_ACTIVITY_ENTRIES_PER_CUBE"];
|
|
304
375
|
const maxDatabaseBytes = environment["BORG_SERVER_MAX_DATABASE_BYTES"];
|
|
305
376
|
const minFreeDiskBytes = environment["BORG_SERVER_MIN_FREE_DISK_BYTES"];
|
|
377
|
+
const sourceSha = environment["BORG_SERVER_SOURCE_SHA"];
|
|
378
|
+
const artifactIntegrity = environment["BORG_SERVER_ARTIFACT_INTEGRITY"];
|
|
379
|
+
const processMode = environment["BORG_SERVER_PROCESS_MODE"];
|
|
380
|
+
const runtimeDirectory = environment["BORG_SERVER_RUNTIME_DIR"];
|
|
381
|
+
if (processMode !== undefined && processMode !== "foreground" && processMode !== "managed") {
|
|
382
|
+
throw new Error("BORG_SERVER_PROCESS_MODE is invalid.");
|
|
383
|
+
}
|
|
306
384
|
return {
|
|
307
385
|
...(keyFile === undefined ? {} : { BORG_SERVER_TLS_KEY_FILE: keyFile }),
|
|
308
386
|
...(certificateFile === undefined
|
|
@@ -316,6 +394,10 @@ export function selectServerEnvironment(environment: NodeJS.ProcessEnv): ServerE
|
|
|
316
394
|
: { BORG_SERVER_MAX_ACTIVITY_ENTRIES_PER_CUBE: maxActivityEntries }),
|
|
317
395
|
...(maxDatabaseBytes === undefined ? {} : { BORG_SERVER_MAX_DATABASE_BYTES: maxDatabaseBytes }),
|
|
318
396
|
...(minFreeDiskBytes === undefined ? {} : { BORG_SERVER_MIN_FREE_DISK_BYTES: minFreeDiskBytes }),
|
|
397
|
+
...(sourceSha === undefined ? {} : { BORG_SERVER_SOURCE_SHA: sourceSha }),
|
|
398
|
+
...(artifactIntegrity === undefined ? {} : { BORG_SERVER_ARTIFACT_INTEGRITY: artifactIntegrity }),
|
|
399
|
+
...(processMode === undefined ? {} : { BORG_SERVER_PROCESS_MODE: processMode }),
|
|
400
|
+
...(runtimeDirectory === undefined ? {} : { BORG_SERVER_RUNTIME_DIR: runtimeDirectory }),
|
|
319
401
|
};
|
|
320
402
|
}
|
|
321
403
|
|
|
@@ -357,6 +439,9 @@ function storageOperatorErrorCode(name: string): OperatorErrorCode {
|
|
|
357
439
|
|
|
358
440
|
const serverEnvironment = selectServerEnvironment(process.env);
|
|
359
441
|
const dataDirectory = serverEnvironment.BORG_SERVER_DATA_DIR ?? join(homedir(), ".borg", "server");
|
|
442
|
+
const runtimeDirectory = serverEnvironment.BORG_SERVER_RUNTIME_DIR ?? join(homedir(), ".borg", "server-runtime");
|
|
443
|
+
const credentialFile = join(homedir(), ".borg", "credentials");
|
|
444
|
+
const nodeRuntimeOperator = createNodeRuntimeOperator(runtimeDirectory, dataDirectory);
|
|
360
445
|
const startOnlyService = createNodeServerService({
|
|
361
446
|
environment: { ...serverEnvironment, BORG_SERVER_DATA_DIR: dataDirectory },
|
|
362
447
|
readFile,
|
|
@@ -365,8 +450,28 @@ const startOnlyService = createNodeServerService({
|
|
|
365
450
|
const running = await startHttpsServer(options);
|
|
366
451
|
return nodeServerTestHooks?.wrapRunningServer?.(running) ?? running;
|
|
367
452
|
},
|
|
368
|
-
onStarted: (origin) => {
|
|
369
|
-
|
|
453
|
+
onStarted: (origin, identity) => {
|
|
454
|
+
if (process.stderr.isTTY !== true || serverEnvironment.BORG_SERVER_PROCESS_MODE === "managed") {
|
|
455
|
+
console.error(JSON.stringify({
|
|
456
|
+
status: "running",
|
|
457
|
+
artifact: `borgmcp-server@${identity.package_version}`,
|
|
458
|
+
artifact_integrity: identity.artifact_integrity,
|
|
459
|
+
build_identity: identity.source_sha,
|
|
460
|
+
endpoint: origin,
|
|
461
|
+
mode: serverEnvironment.BORG_SERVER_PROCESS_MODE ?? "foreground",
|
|
462
|
+
data_identity: "available",
|
|
463
|
+
}));
|
|
464
|
+
} else {
|
|
465
|
+
console.error([
|
|
466
|
+
"Starting verified local server in the foreground.",
|
|
467
|
+
`Artifact: borgmcp-server@${identity.package_version} (${identity.artifact_integrity ?? "unavailable"})`,
|
|
468
|
+
`Build identity: ${identity.source_sha ?? "unavailable"}`,
|
|
469
|
+
`Endpoint: ${origin}`,
|
|
470
|
+
"Data and identity: preserved",
|
|
471
|
+
"Ctrl-C stops the foreground process.",
|
|
472
|
+
"Foreground mode does not manage persistence.",
|
|
473
|
+
].join("\n"));
|
|
474
|
+
}
|
|
370
475
|
nodeServerTestHooks?.onListening?.(origin);
|
|
371
476
|
},
|
|
372
477
|
onStartupPhase: (phase) => nodeServerTestHooks?.onStartupPhase?.(phase) ?? Promise.resolve(),
|
|
@@ -382,14 +487,137 @@ const startOnlyService = createNodeServerService({
|
|
|
382
487
|
});
|
|
383
488
|
export const nodeServerService: ServerService = {
|
|
384
489
|
start: startOnlyService.start,
|
|
385
|
-
setup: (options) =>
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
490
|
+
setup: async (options) => {
|
|
491
|
+
const bindHost = resolveSetupBindHost(serverEnvironment);
|
|
492
|
+
if ((await inspectRuntimeLock(dataDirectory)).running) throw operatorErrors.RUNTIME_ACTIVE;
|
|
493
|
+
const artifact = await nodeRuntimeOperator.prepareLatest(30_000);
|
|
494
|
+
const result = await setupNodeServerInstallation(
|
|
495
|
+
dataDirectory,
|
|
496
|
+
bindHost,
|
|
497
|
+
options,
|
|
498
|
+
credentialFile,
|
|
499
|
+
);
|
|
500
|
+
if (!("existing" in result)) {
|
|
501
|
+
const { recoveryCredential: _recovery, initialInvitation: _invitation, ...publicResult } = result;
|
|
502
|
+
return {
|
|
503
|
+
...publicResult,
|
|
504
|
+
artifact: {
|
|
505
|
+
version: artifact.version,
|
|
506
|
+
integrity: artifact.integrity,
|
|
507
|
+
sourceSha: artifact.sourceSha,
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
...result,
|
|
513
|
+
artifact: {
|
|
514
|
+
version: artifact.version,
|
|
515
|
+
integrity: artifact.integrity,
|
|
516
|
+
sourceSha: artifact.sourceSha,
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
},
|
|
520
|
+
status: () => inspectNodeRuntime(dataDirectory, runtimeDirectory),
|
|
521
|
+
update: () => nodeRuntimeOperator.updateLatest(30_000),
|
|
522
|
+
...createOfflineCredentialService(dataDirectory, credentialFile),
|
|
391
523
|
};
|
|
392
524
|
|
|
525
|
+
function createNodeRuntimeOperator(managedRuntimeDirectory: string, runtimeDataDirectory: string) {
|
|
526
|
+
const platform = process.platform === "darwin" ? "launchd" : "systemd";
|
|
527
|
+
const definition = createManagedServiceDefinition({
|
|
528
|
+
platform,
|
|
529
|
+
nodeExecutable: process.execPath,
|
|
530
|
+
runtimeRoot: managedRuntimeDirectory,
|
|
531
|
+
dataDirectory: runtimeDataDirectory,
|
|
532
|
+
definitionPath: platform === "launchd"
|
|
533
|
+
? join(homedir(), "Library", "LaunchAgents", "ai.borgmcp.server.plist")
|
|
534
|
+
: join(homedir(), ".config", "systemd", "user", "ai.borgmcp.server.service"),
|
|
535
|
+
...(platform === "launchd" ? { launchdDomain: `gui/${process.getuid?.() ?? 0}` } : {}),
|
|
536
|
+
});
|
|
537
|
+
const run = async (command: readonly [string, ...string[]], signal: AbortSignal): Promise<void> => {
|
|
538
|
+
const [executable, ...args] = command;
|
|
539
|
+
await promisify(execFile)(executable, args, {
|
|
540
|
+
signal,
|
|
541
|
+
timeout: 20_000,
|
|
542
|
+
maxBuffer: 64 * 1024,
|
|
543
|
+
encoding: "utf8",
|
|
544
|
+
});
|
|
545
|
+
};
|
|
546
|
+
const lifecycle = createRuntimeLifecycle({
|
|
547
|
+
unpack: createUnixNpmArtifactUnpacker(),
|
|
548
|
+
restart: (signal) => run(definition.restart, signal),
|
|
549
|
+
stop: (signal) => run(definition.stop, signal),
|
|
550
|
+
probe: (signal) => waitForRuntimeIdentity(runtimeDataDirectory, signal),
|
|
551
|
+
});
|
|
552
|
+
return createRuntimeOperator({
|
|
553
|
+
runtimeRoot: managedRuntimeDirectory,
|
|
554
|
+
artifacts: createRegistryArtifactSource(),
|
|
555
|
+
lifecycle,
|
|
556
|
+
isRunning: async () => {
|
|
557
|
+
const status = await inspectRuntimeLock(runtimeDataDirectory);
|
|
558
|
+
if (status.running && status.mode !== "managed") {
|
|
559
|
+
throw new Error("Foreground runtime must be stopped before artifact activation.");
|
|
560
|
+
}
|
|
561
|
+
return status.running;
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function waitForRuntimeIdentity(
|
|
567
|
+
runtimeDataDirectory: string,
|
|
568
|
+
signal: AbortSignal,
|
|
569
|
+
): Promise<RuntimeBuildIdentity> {
|
|
570
|
+
while (!signal.aborted) {
|
|
571
|
+
try {
|
|
572
|
+
const status = await inspectRuntimeLock(runtimeDataDirectory);
|
|
573
|
+
if (status.running && status.identity !== null) return status.identity;
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (error !== operatorErrors.RUNTIME_LOCK_STALE) throw error;
|
|
576
|
+
}
|
|
577
|
+
await new Promise<void>((resolve) => {
|
|
578
|
+
const timer = setTimeout(resolve, 50);
|
|
579
|
+
signal.addEventListener("abort", () => {
|
|
580
|
+
clearTimeout(timer);
|
|
581
|
+
resolve();
|
|
582
|
+
}, { once: true });
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
throw new Error("Managed runtime identity probe was cancelled.");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export async function inspectNodeRuntime(
|
|
589
|
+
runtimeDataDirectory: string,
|
|
590
|
+
managedRuntimeDirectory: string,
|
|
591
|
+
): Promise<ServerRuntimeStatus> {
|
|
592
|
+
const [lock, activeArtifact, dataIdentity] = await Promise.all([
|
|
593
|
+
inspectRuntimeLock(runtimeDataDirectory),
|
|
594
|
+
inspectActiveRuntimeArtifact(managedRuntimeDirectory),
|
|
595
|
+
hasDataIdentity(runtimeDataDirectory),
|
|
596
|
+
]);
|
|
597
|
+
const identity = lock.running ? lock.identity : null;
|
|
598
|
+
const artifact = identity?.artifact_integrity === null || identity === null
|
|
599
|
+
? activeArtifact === null ? null : { version: activeArtifact.version, integrity: activeArtifact.integrity }
|
|
600
|
+
: { version: identity.package_version, integrity: identity.artifact_integrity };
|
|
601
|
+
return Object.freeze({
|
|
602
|
+
status: lock.running ? "running" : "stopped",
|
|
603
|
+
artifact,
|
|
604
|
+
buildIdentity: identity?.source_sha ?? null,
|
|
605
|
+
endpoint: lock.running ? lock.endpoint : null,
|
|
606
|
+
mode: lock.running ? lock.mode : "stopped",
|
|
607
|
+
dataIdentity,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function hasDataIdentity(directory: string): Promise<"available" | "unavailable"> {
|
|
612
|
+
try {
|
|
613
|
+
const metadata = await lstat(join(directory, "server.json"));
|
|
614
|
+
return metadata.isFile() && !metadata.isSymbolicLink() ? "available" : "unavailable";
|
|
615
|
+
} catch (error) {
|
|
616
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "unavailable";
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
393
621
|
const managedInstallationFiles = Object.freeze([
|
|
394
622
|
"borg.db",
|
|
395
623
|
"borg.db-wal",
|
|
@@ -407,18 +635,38 @@ export async function setupNodeServerInstallation(
|
|
|
407
635
|
setupDataDirectory: string,
|
|
408
636
|
bindHost: string,
|
|
409
637
|
options: SetupOptions,
|
|
410
|
-
|
|
638
|
+
credentialRoot?: string,
|
|
639
|
+
): Promise<BootstrapResult | { readonly existing: true }> {
|
|
411
640
|
const directory = await preparePrivateDataDirectory(setupDataDirectory);
|
|
412
641
|
const runtimeLock = await acquireRuntimeLock(directory);
|
|
413
642
|
let invitationLock: RuntimeLock | undefined;
|
|
414
643
|
try {
|
|
415
644
|
invitationLock = await acquireInvitationMintLock(directory);
|
|
416
645
|
const existing = await inspectManagedInstallation(directory);
|
|
417
|
-
if (existing.length !== 0 && !options.reinitialize)
|
|
646
|
+
if (existing.length !== 0 && !options.reinitialize) {
|
|
647
|
+
const names = new Set(existing.map((path) => basename(path)));
|
|
648
|
+
const complete = [
|
|
649
|
+
"borg.db",
|
|
650
|
+
"credential-digest.key",
|
|
651
|
+
"ca.crt",
|
|
652
|
+
"server.key",
|
|
653
|
+
"server.crt",
|
|
654
|
+
"server.json",
|
|
655
|
+
].every((name) => names.has(name));
|
|
656
|
+
if (!complete) throw operatorErrors.INSTALLATION_EXISTS;
|
|
657
|
+
return Object.freeze({ existing: true });
|
|
658
|
+
}
|
|
418
659
|
if (options.reinitialize) {
|
|
419
660
|
for (const path of existing) await unlink(path);
|
|
420
661
|
}
|
|
421
|
-
return await bootstrapServer(
|
|
662
|
+
return await bootstrapServer(
|
|
663
|
+
directory,
|
|
664
|
+
bindHost,
|
|
665
|
+
() => new Date(),
|
|
666
|
+
credentialRoot === undefined
|
|
667
|
+
? async () => undefined
|
|
668
|
+
: (record) => writePortableServerCredential(credentialRoot, record),
|
|
669
|
+
);
|
|
422
670
|
} finally {
|
|
423
671
|
if (invitationLock === undefined) await runtimeLock.release();
|
|
424
672
|
else await invitationLock.release().finally(() => runtimeLock.release());
|
|
@@ -452,9 +700,10 @@ function resolveSetupBindHost(environment: ServerEnvironment): string {
|
|
|
452
700
|
|
|
453
701
|
export function createOfflineCredentialService(
|
|
454
702
|
offlineDataDirectory: string,
|
|
703
|
+
credentialRoot?: string,
|
|
455
704
|
): Pick<Required<ServerService>,
|
|
456
705
|
"rotateClient" | "revokeClient" | "grantClient" | "ungrantClient" |
|
|
457
|
-
"createClientInvitation" | "replaceOwnerInvitation"
|
|
706
|
+
"createClientInvitation" | "replaceOwnerInvitation" | "invite"
|
|
458
707
|
> {
|
|
459
708
|
const withAuthority = async <T>(operation: (
|
|
460
709
|
authority: CredentialAuthority,
|
|
@@ -478,7 +727,9 @@ export function createOfflineCredentialService(
|
|
|
478
727
|
else await invitationLock.release().finally(() => runtimeLock.release());
|
|
479
728
|
}
|
|
480
729
|
};
|
|
481
|
-
const withInvitationAuthority = async <T>(
|
|
730
|
+
const withInvitationAuthority = async <T>(
|
|
731
|
+
operation: (authority: CredentialAuthority) => T | Promise<T>,
|
|
732
|
+
): Promise<T> => {
|
|
482
733
|
const invitationLock = await acquireInvitationMintLock(offlineDataDirectory);
|
|
483
734
|
let offlineRuntimeLock: RuntimeLock | undefined;
|
|
484
735
|
let runtime: Awaited<ReturnType<typeof openStore>> | undefined;
|
|
@@ -493,7 +744,7 @@ export function createOfflineCredentialService(
|
|
|
493
744
|
const digestKey = await loadDigestKey(join(offlineDataDirectory, "credential-digest.key"));
|
|
494
745
|
digester = new CredentialDigester(digestKey);
|
|
495
746
|
digestKey.fill(0);
|
|
496
|
-
return operation(new CredentialAuthority(runtime.credentials, digester));
|
|
747
|
+
return await operation(new CredentialAuthority(runtime.credentials, digester));
|
|
497
748
|
} catch (error) {
|
|
498
749
|
if (error instanceof MigrationCompatibilityError) throw operatorErrors.INVITATION_SCHEMA_MISMATCH;
|
|
499
750
|
if (isSqliteContention(error)) throw operatorErrors.INVITATION_CONTENTION;
|
|
@@ -542,6 +793,22 @@ export function createOfflineCredentialService(
|
|
|
542
793
|
if (invitation === null) throw operatorErrors.RECOVERY_INVALID;
|
|
543
794
|
return invitation;
|
|
544
795
|
}),
|
|
796
|
+
invite: () => withInvitationAuthority(async (authority) => {
|
|
797
|
+
if (credentialRoot === undefined) throw new Error("Local owner credential store is unavailable.");
|
|
798
|
+
const config = JSON.parse((await readFile(join(offlineDataDirectory, "server.json"))).toString("utf8")) as {
|
|
799
|
+
bind_host?: unknown;
|
|
800
|
+
ca_spki_sha256?: unknown;
|
|
801
|
+
};
|
|
802
|
+
if (typeof config.bind_host !== "string" || typeof config.ca_spki_sha256 !== "string") {
|
|
803
|
+
throw new Error("Server identity is invalid.");
|
|
804
|
+
}
|
|
805
|
+
const origin = `https://${config.bind_host === "::1" ? "[::1]" : config.bind_host}:${DEFAULT_PORT}`;
|
|
806
|
+
const trustIdentity = `spki-sha256:${config.ca_spki_sha256}`;
|
|
807
|
+
const record = await readPortableServerCredential(credentialRoot, origin, trustIdentity);
|
|
808
|
+
const invitation = authority.createInvitationForOwnerCredential(record.credential, 15 * 60_000);
|
|
809
|
+
if (invitation === null) throw new Error("Local owner credential is invalid.");
|
|
810
|
+
return invitation;
|
|
811
|
+
}),
|
|
545
812
|
};
|
|
546
813
|
}
|
|
547
814
|
|
|
@@ -560,6 +827,7 @@ function parseInvitationCubeSelector(
|
|
|
560
827
|
|
|
561
828
|
interface RuntimeLock {
|
|
562
829
|
readonly release: () => Promise<void>;
|
|
830
|
+
readonly updateOrigin?: (origin: string) => Promise<void>;
|
|
563
831
|
}
|
|
564
832
|
|
|
565
833
|
async function teardownRuntime(resources: RuntimeResources): Promise<void> {
|
|
@@ -624,19 +892,44 @@ function fatalTeardownError(primary: unknown, cleanup: unknown): FatalTeardownEr
|
|
|
624
892
|
export async function acquireRuntimeLock(
|
|
625
893
|
runtimeDataDirectory: string,
|
|
626
894
|
purpose: "server" | "exclusive-admin" = "exclusive-admin",
|
|
895
|
+
identity?: RuntimeBuildIdentity,
|
|
896
|
+
mode: "foreground" | "managed" = "foreground",
|
|
627
897
|
): Promise<RuntimeLock> {
|
|
628
898
|
const path = join(runtimeDataDirectory, "runtime.lock");
|
|
629
899
|
const nonce = randomUUID();
|
|
630
900
|
try {
|
|
631
901
|
const handle = await open(path, "wx", 0o600);
|
|
902
|
+
const record: {
|
|
903
|
+
readonly pid: number;
|
|
904
|
+
readonly nonce: string;
|
|
905
|
+
readonly purpose: "server" | "exclusive-admin";
|
|
906
|
+
readonly mode: "foreground" | "managed";
|
|
907
|
+
readonly runtime_identity?: RuntimeBuildIdentity;
|
|
908
|
+
endpoint?: string;
|
|
909
|
+
} = {
|
|
910
|
+
pid: process.pid,
|
|
911
|
+
nonce,
|
|
912
|
+
purpose,
|
|
913
|
+
mode,
|
|
914
|
+
...(identity === undefined ? {} : { runtime_identity: identity }),
|
|
915
|
+
};
|
|
632
916
|
try {
|
|
633
|
-
await handle.writeFile(JSON.stringify(
|
|
917
|
+
await handle.writeFile(JSON.stringify(record));
|
|
634
918
|
} catch (error) {
|
|
635
919
|
await handle.close();
|
|
636
920
|
await unlink(path).catch(() => undefined);
|
|
637
921
|
throw error;
|
|
638
922
|
}
|
|
639
923
|
return {
|
|
924
|
+
updateOrigin: async (origin) => {
|
|
925
|
+
if (!/^https:\/\/(?:\[[0-9a-f:]+\]|[0-9.]+):[0-9]{1,5}$/u.test(origin)) {
|
|
926
|
+
throw new Error("Runtime endpoint is invalid.");
|
|
927
|
+
}
|
|
928
|
+
record.endpoint = origin;
|
|
929
|
+
await handle.truncate(0);
|
|
930
|
+
await handle.write(JSON.stringify(record), 0, "utf8");
|
|
931
|
+
await handle.sync();
|
|
932
|
+
},
|
|
640
933
|
release: async () => {
|
|
641
934
|
await handle.close();
|
|
642
935
|
try {
|
|
@@ -666,6 +959,94 @@ export async function acquireRuntimeLock(
|
|
|
666
959
|
}
|
|
667
960
|
}
|
|
668
961
|
|
|
962
|
+
export async function inspectRuntimeLock(runtimeDataDirectory: string): Promise<RuntimeLockStatus> {
|
|
963
|
+
const path = join(runtimeDataDirectory, "runtime.lock");
|
|
964
|
+
let metadata;
|
|
965
|
+
try {
|
|
966
|
+
metadata = await lstat(path);
|
|
967
|
+
} catch (error) {
|
|
968
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return Object.freeze({ running: false });
|
|
969
|
+
throw error;
|
|
970
|
+
}
|
|
971
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0 ||
|
|
972
|
+
metadata.size > 8 * 1024) {
|
|
973
|
+
throw operatorErrors.RUNTIME_LOCK_UNSAFE;
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
const value = JSON.parse(await readFile(path, "utf8")) as {
|
|
977
|
+
pid?: unknown;
|
|
978
|
+
purpose?: unknown;
|
|
979
|
+
runtime_identity?: unknown;
|
|
980
|
+
endpoint?: unknown;
|
|
981
|
+
mode?: unknown;
|
|
982
|
+
};
|
|
983
|
+
if (!Number.isSafeInteger(value.pid) || (value.pid as number) <= 0 || value.purpose !== "server" ||
|
|
984
|
+
(value.endpoint !== undefined &&
|
|
985
|
+
(typeof value.endpoint !== "string" || !isRuntimeEndpoint(value.endpoint))) ||
|
|
986
|
+
(value.mode !== "foreground" && value.mode !== "managed")) {
|
|
987
|
+
throw new Error();
|
|
988
|
+
}
|
|
989
|
+
const pid = value.pid as number;
|
|
990
|
+
if (!processIsAlive(pid)) throw operatorErrors.RUNTIME_LOCK_STALE;
|
|
991
|
+
return Object.freeze({
|
|
992
|
+
running: true,
|
|
993
|
+
pid,
|
|
994
|
+
identity: decodeRuntimeLockIdentity(value.runtime_identity),
|
|
995
|
+
endpoint: value.endpoint ?? null,
|
|
996
|
+
mode: value.mode,
|
|
997
|
+
});
|
|
998
|
+
} catch (error) {
|
|
999
|
+
if (error === operatorErrors.RUNTIME_LOCK_STALE) throw error;
|
|
1000
|
+
throw operatorErrors.RUNTIME_LOCK_INVALID;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function isRuntimeEndpoint(value: string): boolean {
|
|
1005
|
+
if (value.length > 512) return false;
|
|
1006
|
+
try {
|
|
1007
|
+
const endpoint = new URL(value);
|
|
1008
|
+
if (endpoint.protocol !== "https:" || endpoint.username !== "" || endpoint.password !== "" ||
|
|
1009
|
+
endpoint.pathname !== "/" || endpoint.search !== "" || endpoint.hash !== "") return false;
|
|
1010
|
+
const host = endpoint.hostname.startsWith("[") && endpoint.hostname.endsWith("]")
|
|
1011
|
+
? endpoint.hostname.slice(1, -1)
|
|
1012
|
+
: endpoint.hostname;
|
|
1013
|
+
resolveBindOptions({ host, port: Number(endpoint.port || "443"), lanConsent: true });
|
|
1014
|
+
return true;
|
|
1015
|
+
} catch {
|
|
1016
|
+
return false;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function decodeRuntimeLockIdentity(value: unknown): RuntimeBuildIdentity | null {
|
|
1021
|
+
if (value === undefined) return null;
|
|
1022
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error();
|
|
1023
|
+
const identity = value as Record<string, unknown>;
|
|
1024
|
+
const packageVersion = identity["package_version"];
|
|
1025
|
+
const sourceSha = identity["source_sha"];
|
|
1026
|
+
const artifactIntegrity = identity["artifact_integrity"];
|
|
1027
|
+
const protocolVersion = identity["protocol_version"];
|
|
1028
|
+
const startedAt = identity["started_at"];
|
|
1029
|
+
if (typeof packageVersion !== "string" ||
|
|
1030
|
+
!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/u.test(packageVersion) ||
|
|
1031
|
+
(sourceSha !== null &&
|
|
1032
|
+
(typeof sourceSha !== "string" || !/^[0-9a-f]{40}$/u.test(sourceSha))) ||
|
|
1033
|
+
(artifactIntegrity !== null &&
|
|
1034
|
+
(typeof artifactIntegrity !== "string" ||
|
|
1035
|
+
!/^sha512-[A-Za-z0-9+/]{86}==$/u.test(artifactIntegrity))) ||
|
|
1036
|
+
typeof protocolVersion !== "string" || protocolVersion.length < 1 || protocolVersion.length > 32 ||
|
|
1037
|
+
typeof startedAt !== "string" || startedAt.length > 64 ||
|
|
1038
|
+
!Number.isFinite(Date.parse(startedAt)) || new Date(startedAt).toISOString() !== startedAt) {
|
|
1039
|
+
throw new Error();
|
|
1040
|
+
}
|
|
1041
|
+
return Object.freeze({
|
|
1042
|
+
package_version: packageVersion,
|
|
1043
|
+
source_sha: sourceSha as string | null,
|
|
1044
|
+
artifact_integrity: artifactIntegrity as string | null,
|
|
1045
|
+
protocol_version: protocolVersion,
|
|
1046
|
+
started_at: startedAt,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
669
1050
|
export async function acquireInvitationMintLock(runtimeDataDirectory: string): Promise<RuntimeLock> {
|
|
670
1051
|
const path = join(runtimeDataDirectory, "invitation-mint.lock");
|
|
671
1052
|
const nonce = randomUUID();
|