borgmcp-server 0.1.5 → 0.1.8
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 +32 -11
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/coordination-api.js +40 -6
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +4 -1
- package/dist/credentials.js +28 -3
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +1 -1
- package/dist/debug-log.js +1 -1
- package/dist/debug-log.js.map +1 -1
- package/dist/https-server.d.ts +2 -1
- package/dist/https-server.js +68 -20
- package/dist/https-server.js.map +1 -1
- package/dist/migrations.js +23 -0
- package/dist/migrations.js.map +1 -1
- package/dist/store.d.ts +39 -7
- package/dist/store.js +230 -83
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
- package/src/coordination-api.ts +46 -4
- package/src/credentials.ts +29 -5
- package/src/debug-log.ts +2 -2
- package/src/https-server.ts +97 -20
- package/src/migrations.ts +23 -0
- package/src/store.ts +251 -83
package/src/debug-log.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type DebugRoute =
|
|
|
21
21
|
export type DebugEvent =
|
|
22
22
|
| { readonly event: "startup"; readonly bindMode: "loopback" | "lan"; readonly port: number; readonly dataDirectory: "configured" | "tls_only" }
|
|
23
23
|
| { 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 }
|
|
24
|
+
| { 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
25
|
| { 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
26
|
| { 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
27
|
| { readonly event: "ack_write"; readonly cubeId: string; readonly entryId: string; readonly kind: "ack" | "claim"; readonly principal: Principal }
|
|
@@ -160,6 +160,6 @@ function enumValue<const T extends string>(value: unknown, allowed: readonly T[]
|
|
|
160
160
|
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
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
162
|
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OTHER"] as const;
|
|
163
|
-
const AUTH_RESULTS = ["not_required", "missing", "invalid", "revoked", "evicted", "accepted"] as const;
|
|
163
|
+
const AUTH_RESULTS = ["not_required", "missing", "invalid", "expired", "revoked", "evicted", "rejected", "accepted"] as const;
|
|
164
164
|
const AUTHZ_RESULTS = ["not_checked", "accepted", "denied_or_not_found"] as const;
|
|
165
165
|
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
|
@@ -39,7 +39,7 @@ export interface ServiceLimits {
|
|
|
39
39
|
|
|
40
40
|
export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
|
|
41
41
|
maxConnections: 100,
|
|
42
|
-
maxConnectionsPerAddress:
|
|
42
|
+
maxConnectionsPerAddress: 30,
|
|
43
43
|
maxRequestsPerWindow: 120,
|
|
44
44
|
maxRequestsPerAddressWindow: 600,
|
|
45
45
|
maxRequestsGlobalWindow: 5_000,
|
|
@@ -48,7 +48,7 @@ export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
|
|
|
48
48
|
maxStreamsPerCredential: 8,
|
|
49
49
|
maxHeaderBytes: 16_384,
|
|
50
50
|
maxRequestBodyBytes: 65_536,
|
|
51
|
-
maxRequestsPerSocket:
|
|
51
|
+
maxRequestsPerSocket: 0,
|
|
52
52
|
requestTimeoutMs: 15_000,
|
|
53
53
|
tlsHandshakeTimeoutMs: 10_000,
|
|
54
54
|
headersTimeoutMs: 10_000,
|
|
@@ -63,7 +63,7 @@ export interface RequestHandlerContext {
|
|
|
63
63
|
readonly authorizeCoordination?: (
|
|
64
64
|
authorization: string | undefined,
|
|
65
65
|
signal: AbortSignal,
|
|
66
|
-
) => Promise<Principal | "missing" | "invalid" | "revoked" | "evicted">;
|
|
66
|
+
) => Promise<Principal | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected">;
|
|
67
67
|
readonly handleCoordination?: (request: CoordinationRequest) => Promise<CoordinationResponse>;
|
|
68
68
|
readonly debugLogger: DebugLogger;
|
|
69
69
|
}
|
|
@@ -82,6 +82,7 @@ export interface HttpsServerOptions {
|
|
|
82
82
|
readonly debugLogger?: DebugLogger;
|
|
83
83
|
readonly testHooks?: {
|
|
84
84
|
readonly identifyRemoteAddress?: (socket: Socket) => string;
|
|
85
|
+
readonly identifyConnectionAddress?: (socket: Socket) => string;
|
|
85
86
|
};
|
|
86
87
|
}
|
|
87
88
|
|
|
@@ -100,6 +101,15 @@ export async function startHttpsServer(options: HttpsServerOptions): Promise<Run
|
|
|
100
101
|
validateLimits(limits);
|
|
101
102
|
validateTlsCertificate(options.tls.cert, bind.host, bind.mode, options.tls.ca);
|
|
102
103
|
const handlerContext = createRequestHandlerContext(options);
|
|
104
|
+
const identifyRemoteAddress = options.testHooks?.identifyRemoteAddress ??
|
|
105
|
+
((socket: Socket) => socket.remoteAddress ?? "unknown");
|
|
106
|
+
const identifyConnectionAddress = options.testHooks?.identifyConnectionAddress ??
|
|
107
|
+
((socket: Socket) => socket.remoteAddress ?? "unknown");
|
|
108
|
+
const addressConnectionLimiter = new AddressConnectionLimiter(
|
|
109
|
+
limits.maxConnectionsPerAddress,
|
|
110
|
+
limits.maxConnections,
|
|
111
|
+
identifyConnectionAddress,
|
|
112
|
+
);
|
|
103
113
|
|
|
104
114
|
const server = createServer(
|
|
105
115
|
{
|
|
@@ -112,11 +122,19 @@ export async function startHttpsServer(options: HttpsServerOptions): Promise<Run
|
|
|
112
122
|
headersTimeout: limits.headersTimeoutMs,
|
|
113
123
|
keepAliveTimeout: limits.keepAliveTimeoutMs,
|
|
114
124
|
},
|
|
115
|
-
createRequestListener(
|
|
125
|
+
createRequestListener(
|
|
126
|
+
handlerContext,
|
|
127
|
+
limits,
|
|
128
|
+
identifyRemoteAddress,
|
|
129
|
+
(socket) => addressConnectionLimiter.isRejected(socket),
|
|
130
|
+
),
|
|
116
131
|
);
|
|
117
132
|
|
|
118
|
-
const acceptedSockets = applyServerLimits(server, limits);
|
|
119
|
-
server.on("secureConnection", (socket) =>
|
|
133
|
+
const acceptedSockets = applyServerLimits(server, limits, addressConnectionLimiter);
|
|
134
|
+
server.on("secureConnection", (socket) => {
|
|
135
|
+
socket.disableRenegotiation();
|
|
136
|
+
addressConnectionLimiter.admit(socket);
|
|
137
|
+
});
|
|
120
138
|
server.on("tlsClientError", (_error, socket) => {
|
|
121
139
|
handlerContext.debugLogger.emit({ event: "transport_rejection", reason: "tls_client_error" });
|
|
122
140
|
socket.destroy();
|
|
@@ -173,6 +191,7 @@ function createRequestListener(
|
|
|
173
191
|
context: RequestHandlerContext,
|
|
174
192
|
limits: ServiceLimits,
|
|
175
193
|
identifyRemoteAddress: (socket: Socket) => string = (socket) => socket.remoteAddress ?? "unknown",
|
|
194
|
+
isConnectionRejected: (socket: Socket) => boolean = () => false,
|
|
176
195
|
): (request: IncomingMessage, response: ServerResponse) => void {
|
|
177
196
|
const admissionLimiter = new PreAuthAdmissionLimiter(limits);
|
|
178
197
|
const credentialRateLimiter = new RequestRateLimiter(limits, limits.maxRequestsPerWindow);
|
|
@@ -204,6 +223,11 @@ function createRequestListener(
|
|
|
204
223
|
};
|
|
205
224
|
response.once("finish", emitDebug);
|
|
206
225
|
response.once("close", emitDebug);
|
|
226
|
+
if (isConnectionRejected(request.socket)) {
|
|
227
|
+
request.resume();
|
|
228
|
+
sendRateLimited(response, 1);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
207
231
|
const controller = new AbortController();
|
|
208
232
|
response.once("close", () => controller.abort());
|
|
209
233
|
let timer: NodeJS.Timeout | undefined;
|
|
@@ -343,16 +367,16 @@ async function handleRequest(
|
|
|
343
367
|
sendJson(response, 410, protocolError(ErrorCode.DRONE_EVICTED, "Authentication failed."));
|
|
344
368
|
return;
|
|
345
369
|
}
|
|
346
|
-
const code = authentication === "
|
|
347
|
-
?
|
|
370
|
+
const code = authentication === "expired"
|
|
371
|
+
? ErrorCode.AUTH_EXPIRED
|
|
372
|
+
: authentication === "revoked" ? ErrorCode.SESSION_REVOKED
|
|
373
|
+
: authentication === "rejected" ? ErrorCode.SESSION_REJECTED
|
|
348
374
|
: authentication === "missing" || authorization === undefined ? "AUTH_MISSING" : "AUTH_INVALID";
|
|
349
375
|
sendJson(response, 401, protocolError(code, "Authentication failed."));
|
|
350
376
|
return;
|
|
351
377
|
}
|
|
352
378
|
trace.principal = authentication;
|
|
353
|
-
const clientIdentity =
|
|
354
|
-
? authentication.clientId
|
|
355
|
-
: authentication.id}`;
|
|
379
|
+
const clientIdentity = credentialRateLimitIdentity(authentication, request.method, path);
|
|
356
380
|
const credentialRetry = credentialRateLimiter.consume(clientIdentity);
|
|
357
381
|
if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
|
|
358
382
|
const query = parseCoordinationQuery(request.url, path);
|
|
@@ -393,7 +417,7 @@ async function handleRequest(
|
|
|
393
417
|
interface RequestTrace {
|
|
394
418
|
readonly route: DebugRoute;
|
|
395
419
|
readonly method: string;
|
|
396
|
-
authentication: "not_required" | "missing" | "invalid" | "revoked" | "evicted" | "accepted";
|
|
420
|
+
authentication: "not_required" | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected" | "accepted";
|
|
397
421
|
principal?: Principal;
|
|
398
422
|
}
|
|
399
423
|
|
|
@@ -592,7 +616,24 @@ function isCoordinationPath(path: string | null): path is string {
|
|
|
592
616
|
path?.startsWith("/api/cubes/") === true;
|
|
593
617
|
}
|
|
594
618
|
|
|
595
|
-
function
|
|
619
|
+
function credentialRateLimitIdentity(
|
|
620
|
+
principal: Principal,
|
|
621
|
+
method: string | undefined,
|
|
622
|
+
path: string,
|
|
623
|
+
): string {
|
|
624
|
+
const routineRead = (method === "GET" && !path.endsWith("/stream")) ||
|
|
625
|
+
(method === "PUT" && (path.endsWith("/logs") || path.endsWith("/decisions")));
|
|
626
|
+
if (principal.kind === "drone-session" && routineRead) {
|
|
627
|
+
return `drone-session:${principal.id}`;
|
|
628
|
+
}
|
|
629
|
+
return `client:${principal.kind === "drone-session" ? principal.clientId : principal.id}`;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function applyServerLimits(
|
|
633
|
+
server: HttpsServer,
|
|
634
|
+
limits: ServiceLimits,
|
|
635
|
+
addressConnectionLimiter: AddressConnectionLimiter,
|
|
636
|
+
): Set<Socket> {
|
|
596
637
|
server.maxConnections = limits.maxConnections;
|
|
597
638
|
server.maxRequestsPerSocket = limits.maxRequestsPerSocket;
|
|
598
639
|
server.requestTimeout = limits.requestTimeoutMs;
|
|
@@ -602,27 +643,63 @@ function applyServerLimits(server: HttpsServer, limits: ServiceLimits): Set<Sock
|
|
|
602
643
|
Math.min(limits.handlerTimeoutMs * 2, 2_147_483_647),
|
|
603
644
|
(socket) => socket.destroy(),
|
|
604
645
|
);
|
|
605
|
-
const addressConnections = new ConcurrentQuota(limits.maxConnectionsPerAddress);
|
|
606
646
|
const acceptedSockets = new Set<Socket>();
|
|
607
647
|
server.on("connection", (socket) => {
|
|
608
648
|
const tracked = socket as Socket;
|
|
609
649
|
acceptedSockets.add(tracked);
|
|
610
650
|
tracked.once("close", () => acceptedSockets.delete(tracked));
|
|
611
|
-
|
|
612
|
-
|
|
651
|
+
addressConnectionLimiter.admitRaw(tracked);
|
|
652
|
+
});
|
|
653
|
+
return acceptedSockets;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
class AddressConnectionLimiter {
|
|
657
|
+
readonly #rawConnections: ConcurrentQuota;
|
|
658
|
+
readonly #connections: ConcurrentQuota;
|
|
659
|
+
readonly #rejected = new WeakSet<Socket>();
|
|
660
|
+
readonly #identifyRemoteAddress: (socket: Socket) => string;
|
|
661
|
+
|
|
662
|
+
constructor(
|
|
663
|
+
limit: number,
|
|
664
|
+
globalLimit: number,
|
|
665
|
+
identifyRemoteAddress: (socket: Socket) => string,
|
|
666
|
+
) {
|
|
667
|
+
// Keep exactly one per-address raw socket available beyond normal secure
|
|
668
|
+
// admission so it can receive a controlled HTTP 429 after TLS. All later
|
|
669
|
+
// raw sockets are rejected before they can consume the global pool.
|
|
670
|
+
this.#rawConnections = new ConcurrentQuota(Math.min(limit + 1, globalLimit));
|
|
671
|
+
this.#connections = new ConcurrentQuota(limit);
|
|
672
|
+
this.#identifyRemoteAddress = identifyRemoteAddress;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
admitRaw(socket: Socket): void {
|
|
676
|
+
const release = this.#rawConnections.acquire(this.#identifyRemoteAddress(socket));
|
|
613
677
|
if (release === null) {
|
|
614
678
|
socket.destroy();
|
|
615
679
|
return;
|
|
616
680
|
}
|
|
617
681
|
socket.once("close", release);
|
|
618
|
-
}
|
|
619
|
-
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
admit(socket: Socket): void {
|
|
685
|
+
const release = this.#connections.acquire(this.#identifyRemoteAddress(socket));
|
|
686
|
+
if (release === null) {
|
|
687
|
+
this.#rejected.add(socket);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
socket.once("close", release);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
isRejected(socket: Socket): boolean {
|
|
694
|
+
return this.#rejected.has(socket);
|
|
695
|
+
}
|
|
620
696
|
}
|
|
621
697
|
|
|
622
698
|
function validateLimits(limits: ServiceLimits): void {
|
|
623
699
|
for (const [name, value] of Object.entries(limits)) {
|
|
624
|
-
if (!Number.isSafeInteger(value) || value
|
|
625
|
-
|
|
700
|
+
if (!Number.isSafeInteger(value) || value < 0 || (value === 0 && name !== "maxRequestsPerSocket")) {
|
|
701
|
+
const range = name === "maxRequestsPerSocket" ? "non-negative" : "positive";
|
|
702
|
+
throw new Error(`${name} must be a ${range} safe integer.`);
|
|
626
703
|
}
|
|
627
704
|
}
|
|
628
705
|
if (limits.headersTimeoutMs > limits.requestTimeoutMs) {
|
package/src/migrations.ts
CHANGED
|
@@ -378,6 +378,29 @@ export const STORE_MIGRATIONS: readonly Migration[] = Object.freeze([
|
|
|
378
378
|
) STRICT;
|
|
379
379
|
`,
|
|
380
380
|
},
|
|
381
|
+
{
|
|
382
|
+
version: 12,
|
|
383
|
+
name: "drone_session_supersession",
|
|
384
|
+
sql: `
|
|
385
|
+
ALTER TABLE drone_sessions ADD COLUMN superseded_at TEXT;
|
|
386
|
+
|
|
387
|
+
WITH lineage AS (
|
|
388
|
+
SELECT rowid AS session_rowid,
|
|
389
|
+
LEAD(created_at) OVER (
|
|
390
|
+
PARTITION BY client_id, cube_id, drone_id
|
|
391
|
+
ORDER BY created_at, rowid
|
|
392
|
+
) AS successor_at
|
|
393
|
+
FROM drone_sessions
|
|
394
|
+
)
|
|
395
|
+
UPDATE drone_sessions
|
|
396
|
+
SET superseded_at = (
|
|
397
|
+
SELECT successor_at FROM lineage WHERE session_rowid = drone_sessions.rowid
|
|
398
|
+
)
|
|
399
|
+
WHERE rowid IN (
|
|
400
|
+
SELECT session_rowid FROM lineage WHERE successor_at IS NOT NULL
|
|
401
|
+
);
|
|
402
|
+
`,
|
|
403
|
+
},
|
|
381
404
|
]);
|
|
382
405
|
|
|
383
406
|
interface AppliedMigrationRow {
|