@scp-dev/broker 0.4.2 → 0.5.0
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/index.d.ts +13 -0
- package/dist/index.js +303 -39
- package/package.json +3 -3
- package/src/bridge.ts +32 -1
- package/src/local-sleep-waker.ts +29 -0
- package/src/server.ts +69 -4
- package/src/wakers.ts +232 -0
package/dist/index.d.ts
CHANGED
|
@@ -234,6 +234,8 @@ interface Bridge {
|
|
|
234
234
|
}): Promise<SendResult>;
|
|
235
235
|
/** a local endpoint was claimed, let go or changed its status: mirrored as a delta */
|
|
236
236
|
changed(endpoint: string): void;
|
|
237
|
+
/** mirrors these endpoints now and resolves true once the hub took the delta, false when it did not or none is mirrored */
|
|
238
|
+
mirrorNow?(endpoints: string[]): Promise<boolean>;
|
|
237
239
|
/** rules, caps, groups or a title changed, which can touch several endpoints: every mirrored endpoint is sent again */
|
|
238
240
|
resync(): void;
|
|
239
241
|
/** a local endpoint took another handle: the hub renames its mirror in place, keeping what queued for it */
|
|
@@ -244,6 +246,13 @@ interface Bridge {
|
|
|
244
246
|
local?(event: LocalDelivery): void;
|
|
245
247
|
/** the endpoints of other accounts this account's contacts let it reach */
|
|
246
248
|
contacts(): Promise<XListed[]>;
|
|
249
|
+
/** sets (or with a null url clears) the URL the hub calls to wake this machine; the secret signs those calls and is never shown again */
|
|
250
|
+
machineWake?(params: {
|
|
251
|
+
url: string | null;
|
|
252
|
+
secret?: string;
|
|
253
|
+
}): Promise<{
|
|
254
|
+
set: boolean;
|
|
255
|
+
}>;
|
|
247
256
|
status(): BridgeStatus;
|
|
248
257
|
/** reads the credential again and reconnects now, also after the hub revoked or replaced this bridge */
|
|
249
258
|
relink(): void;
|
|
@@ -340,6 +349,10 @@ interface BrokerOptions {
|
|
|
340
349
|
env?: Record<string, string | undefined> | undefined;
|
|
341
350
|
/** the judge model client for a policy's `judge.model` (default the provider's HTTP API) */
|
|
342
351
|
judgeClient?: JudgeClientFactory | undefined;
|
|
352
|
+
/** the wake hooks' configuration file (default wakers.json beside the policy file), read again when it changes */
|
|
353
|
+
wakersPath?: string | undefined;
|
|
354
|
+
/** the wake hooks by provider, in place of the file */
|
|
355
|
+
wakers?: Record<string, unknown> | undefined;
|
|
343
356
|
}
|
|
344
357
|
/** what a link gets from the broker: its core, the remote hooks a bridge gets, and a send as a local endpoint or a mirrored one through the judge */
|
|
345
358
|
interface LinkContext {
|
package/dist/index.js
CHANGED
|
@@ -446,16 +446,16 @@ var RelayClient = class _RelayClient {
|
|
|
446
446
|
this.fail(new Error("the relay broker sent a line that is not JSON"));
|
|
447
447
|
return;
|
|
448
448
|
}
|
|
449
|
-
const
|
|
450
|
-
if (
|
|
451
|
-
for (const fn of this.listeners) fn(
|
|
449
|
+
const message2 = parsed;
|
|
450
|
+
if (message2.method) {
|
|
451
|
+
for (const fn of this.listeners) fn(message2.method, message2.params);
|
|
452
452
|
continue;
|
|
453
453
|
}
|
|
454
|
-
const pending =
|
|
454
|
+
const pending = message2.id !== void 0 ? this.pending.get(message2.id) : void 0;
|
|
455
455
|
if (!pending) continue;
|
|
456
|
-
this.pending.delete(
|
|
457
|
-
if (
|
|
458
|
-
else pending.resolve(
|
|
456
|
+
this.pending.delete(message2.id);
|
|
457
|
+
if (message2.error) pending.reject(new RelayError(message2.error.data?.code ?? "invalid_params", message2.error.message, message2.error.data?.retry_after));
|
|
458
|
+
else pending.resolve(message2.result);
|
|
459
459
|
}
|
|
460
460
|
}
|
|
461
461
|
fail(error) {
|
|
@@ -534,6 +534,7 @@ import {
|
|
|
534
534
|
XLIMITS
|
|
535
535
|
} from "@scp-dev/core";
|
|
536
536
|
import { HUB_LIMITS, LOCAL_EVENTS } from "@scp-dev/hub";
|
|
537
|
+
var MIRROR_NOW_BATCH = 32;
|
|
537
538
|
var LOCAL_BUFFER = 1e3;
|
|
538
539
|
var idHash = (id) => createHash2("sha256").update(id).digest("hex").slice(0, 32);
|
|
539
540
|
var CONNECT_TIMEOUT_MS = 15e3;
|
|
@@ -822,12 +823,12 @@ function startBridge(options) {
|
|
|
822
823
|
return;
|
|
823
824
|
}
|
|
824
825
|
}
|
|
825
|
-
async function deliver(seq, target,
|
|
826
|
+
async function deliver(seq, target, message2) {
|
|
826
827
|
try {
|
|
827
828
|
let refused = null;
|
|
828
829
|
for (; ; ) {
|
|
829
830
|
try {
|
|
830
|
-
refused = inject(target,
|
|
831
|
+
refused = inject(target, message2);
|
|
831
832
|
break;
|
|
832
833
|
} catch (error) {
|
|
833
834
|
if (error instanceof RelayError2) {
|
|
@@ -841,7 +842,7 @@ function startBridge(options) {
|
|
|
841
842
|
if (attempts >= DEAD_LETTER_ATTEMPTS) {
|
|
842
843
|
await settled("reject", { seq, code: "dead_letter" });
|
|
843
844
|
settle(seq);
|
|
844
|
-
core.record("dead_letter", null, typeof
|
|
845
|
+
core.record("dead_letter", null, typeof message2?.id === "string" ? message2.id : null, { seq, target: String(target), attempts, error: why });
|
|
845
846
|
return;
|
|
846
847
|
}
|
|
847
848
|
await new Promise((resolve) => setTimeout(resolve, injectBackoffMs * 2 ** (attempts - 1)));
|
|
@@ -859,20 +860,20 @@ function startBridge(options) {
|
|
|
859
860
|
log(`relay bridge: delivery ${seq} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
860
861
|
}
|
|
861
862
|
}
|
|
862
|
-
function inject(target,
|
|
863
|
+
function inject(target, message2) {
|
|
863
864
|
const endpoint = core.localTarget(target);
|
|
864
|
-
if ((hooks.policyError() ?? core.inboundRefusal(endpoint,
|
|
865
|
-
if (core.admit(endpoint,
|
|
865
|
+
if ((hooks.policyError() ?? core.inboundRefusal(endpoint, message2)) !== null) return "policy_refused";
|
|
866
|
+
if (core.admit(endpoint, message2).duplicate) return null;
|
|
866
867
|
hooks.wake(endpoint);
|
|
867
868
|
hooks.fleet({
|
|
868
869
|
type: "message",
|
|
869
|
-
machine:
|
|
870
|
-
id:
|
|
871
|
-
thread:
|
|
872
|
-
kind:
|
|
873
|
-
from: localAddress(
|
|
870
|
+
machine: message2.from.machine ?? null,
|
|
871
|
+
id: message2.id,
|
|
872
|
+
thread: message2.thread,
|
|
873
|
+
kind: message2.kind,
|
|
874
|
+
from: localAddress(message2.from.address),
|
|
874
875
|
to: [core.addressOf(endpoint)],
|
|
875
|
-
hop:
|
|
876
|
+
hop: message2.hop
|
|
876
877
|
});
|
|
877
878
|
return null;
|
|
878
879
|
}
|
|
@@ -999,6 +1000,24 @@ function startBridge(options) {
|
|
|
999
1000
|
}
|
|
1000
1001
|
if (mirrored(view)) fireAndForget("mirror", { endpoints: [outward(view)], full: false });
|
|
1001
1002
|
}
|
|
1003
|
+
async function mirrorNow(endpoints) {
|
|
1004
|
+
if (!ready) return false;
|
|
1005
|
+
const views = endpoints.flatMap((id) => {
|
|
1006
|
+
try {
|
|
1007
|
+
const view = core.whoami(id);
|
|
1008
|
+
return mirrored(view) ? [outward(view)] : [];
|
|
1009
|
+
} catch {
|
|
1010
|
+
return [];
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
if (!views.length) return false;
|
|
1014
|
+
try {
|
|
1015
|
+
for (let i = 0; i < views.length; i += MIRROR_NOW_BATCH) await request("mirror", { endpoints: views.slice(i, i + MIRROR_NOW_BATCH), full: false });
|
|
1016
|
+
return true;
|
|
1017
|
+
} catch {
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1002
1021
|
function renamed(endpoint, was) {
|
|
1003
1022
|
if (!ready) return;
|
|
1004
1023
|
let view;
|
|
@@ -1054,6 +1073,10 @@ function startBridge(options) {
|
|
|
1054
1073
|
if (!connected) throw unavailable();
|
|
1055
1074
|
return (await request("list", { contacts: true })).contacts;
|
|
1056
1075
|
}
|
|
1076
|
+
async function machineWake(params) {
|
|
1077
|
+
if (!connected) throw unavailable();
|
|
1078
|
+
return request("machine_wake", params);
|
|
1079
|
+
}
|
|
1057
1080
|
function status() {
|
|
1058
1081
|
return { enabled: true, machine, url: options.url, connected, last_error: lastError, since };
|
|
1059
1082
|
}
|
|
@@ -1083,12 +1106,12 @@ function startBridge(options) {
|
|
|
1083
1106
|
if (socket && socket.readyState <= 1) socket.close(1e3, "the broker stopped");
|
|
1084
1107
|
}
|
|
1085
1108
|
connect();
|
|
1086
|
-
return { forward, changed, resync, renamed, activity, local, contacts, status, relink, stop };
|
|
1109
|
+
return { forward, changed, mirrorNow, resync, renamed, activity, local, contacts, machineWake, status, relink, stop };
|
|
1087
1110
|
}
|
|
1088
1111
|
|
|
1089
1112
|
// src/server.ts
|
|
1090
1113
|
import { createHash as createHash4 } from "crypto";
|
|
1091
|
-
import { chmodSync as chmodSync2, existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as
|
|
1114
|
+
import { chmodSync as chmodSync2, existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as readFileSync7, realpathSync, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
|
|
1092
1115
|
import { createServer } from "net";
|
|
1093
1116
|
import { homedir as homedir2 } from "os";
|
|
1094
1117
|
import { dirname as dirname2, isAbsolute, join as join4, sep } from "path";
|
|
@@ -1223,10 +1246,199 @@ function openDatabase(path) {
|
|
|
1223
1246
|
};
|
|
1224
1247
|
}
|
|
1225
1248
|
|
|
1249
|
+
// src/wakers.ts
|
|
1250
|
+
import { spawn } from "child_process";
|
|
1251
|
+
import { readFileSync as readFileSync6, statSync } from "fs";
|
|
1252
|
+
import { WAKE_PROVIDER, wakeDeclineOf } from "@scp-dev/core";
|
|
1253
|
+
var TIMEOUT_DEFAULT_S = 60;
|
|
1254
|
+
var TIMEOUT_MAX_S = 3600;
|
|
1255
|
+
var STDERR_KEEP = 1024;
|
|
1256
|
+
var STDOUT_KEEP = 4096;
|
|
1257
|
+
var WAKE_DECLINED_EXIT = 77;
|
|
1258
|
+
function declineOf(code, stdout) {
|
|
1259
|
+
if (code !== WAKE_DECLINED_EXIT) return null;
|
|
1260
|
+
try {
|
|
1261
|
+
return wakeDeclineOf(JSON.parse(stdout).declined);
|
|
1262
|
+
} catch {
|
|
1263
|
+
return null;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
function parseWakers(value) {
|
|
1267
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("wakers is an object of provider names");
|
|
1268
|
+
const out = {};
|
|
1269
|
+
for (const [provider, entry] of Object.entries(value)) {
|
|
1270
|
+
if (!WAKE_PROVIDER.test(provider)) throw new Error(`${JSON.stringify(provider)} is not a provider name`);
|
|
1271
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error(`waker ${provider} is not an object`);
|
|
1272
|
+
const { command, timeout_s } = entry;
|
|
1273
|
+
if (!Array.isArray(command) || !command.length || !command.every((part) => typeof part === "string" && part.length > 0))
|
|
1274
|
+
throw new Error(`waker ${provider}: command is a nonempty array of strings`);
|
|
1275
|
+
const timeout = timeout_s ?? TIMEOUT_DEFAULT_S;
|
|
1276
|
+
if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 1 || timeout > TIMEOUT_MAX_S)
|
|
1277
|
+
throw new Error(`waker ${provider}: timeout_s is a whole number of seconds from 1 to ${TIMEOUT_MAX_S}`);
|
|
1278
|
+
out[provider] = { command, timeout_s: timeout };
|
|
1279
|
+
}
|
|
1280
|
+
return out;
|
|
1281
|
+
}
|
|
1282
|
+
function startWakers(options) {
|
|
1283
|
+
const busy = /* @__PURE__ */ new Set();
|
|
1284
|
+
const children = /* @__PURE__ */ new Set();
|
|
1285
|
+
let stopped = false;
|
|
1286
|
+
let fixed = null;
|
|
1287
|
+
if (options.wakers !== void 0) {
|
|
1288
|
+
try {
|
|
1289
|
+
fixed = parseWakers(options.wakers);
|
|
1290
|
+
} catch (error) {
|
|
1291
|
+
options.log(`relay broker: the wakers are invalid (${message(error)}); no wake hook runs`);
|
|
1292
|
+
fixed = {};
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
let loaded = null;
|
|
1296
|
+
function configs() {
|
|
1297
|
+
if (fixed) return fixed;
|
|
1298
|
+
const stat = statSync(options.path, { throwIfNoEntry: false });
|
|
1299
|
+
if (!stat) {
|
|
1300
|
+
loaded = null;
|
|
1301
|
+
return {};
|
|
1302
|
+
}
|
|
1303
|
+
if (loaded && loaded.mtime === stat.mtimeMs && loaded.size === stat.size) return loaded.wakers;
|
|
1304
|
+
let wakers = {};
|
|
1305
|
+
try {
|
|
1306
|
+
wakers = parseWakers(JSON.parse(readFileSync6(options.path, "utf8")));
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
options.log(`relay broker: the wakers at ${options.path} are invalid (${message(error)}); no wake hook runs until it is fixed`);
|
|
1309
|
+
}
|
|
1310
|
+
loaded = { mtime: stat.mtimeMs, size: stat.size, wakers };
|
|
1311
|
+
return wakers;
|
|
1312
|
+
}
|
|
1313
|
+
function finish(job, started, result) {
|
|
1314
|
+
busy.delete(job.endpoint);
|
|
1315
|
+
if (stopped) return;
|
|
1316
|
+
try {
|
|
1317
|
+
if (options.core.wakeResult(job.endpoint, { ...result, ms: options.now() - started, message: job.message.id })) options.changed(job.endpoint);
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
options.log(`relay broker: a wake result was not recorded: ${message(error)}`);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
function exec(config, input, env) {
|
|
1323
|
+
return new Promise((resolve) => {
|
|
1324
|
+
let child;
|
|
1325
|
+
try {
|
|
1326
|
+
child = spawn(config.command[0], config.command.slice(1), { stdio: ["pipe", "pipe", "pipe"], env, detached: true });
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
resolve({ ok: false, error: message(error), stdout: "" });
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
children.add(child);
|
|
1332
|
+
let stdout = "";
|
|
1333
|
+
let stderr = "";
|
|
1334
|
+
let settled = false;
|
|
1335
|
+
let timedOut = false;
|
|
1336
|
+
const timer = setTimeout(() => {
|
|
1337
|
+
timedOut = true;
|
|
1338
|
+
kill(child);
|
|
1339
|
+
}, config.timeout_s * 1e3);
|
|
1340
|
+
const settle = (result) => {
|
|
1341
|
+
if (settled) return;
|
|
1342
|
+
settled = true;
|
|
1343
|
+
clearTimeout(timer);
|
|
1344
|
+
children.delete(child);
|
|
1345
|
+
resolve(result);
|
|
1346
|
+
};
|
|
1347
|
+
child.stdout?.on("data", (chunk) => {
|
|
1348
|
+
if (stdout.length < STDOUT_KEEP) stdout += chunk.toString("utf8").slice(0, STDOUT_KEEP - stdout.length);
|
|
1349
|
+
});
|
|
1350
|
+
child.stderr?.on("data", (chunk) => {
|
|
1351
|
+
if (stderr.length < STDERR_KEEP) stderr += chunk.toString("utf8").slice(0, STDERR_KEEP - stderr.length);
|
|
1352
|
+
});
|
|
1353
|
+
child.on("error", (error) => settle({ ok: false, error: message(error), stdout: "" }));
|
|
1354
|
+
child.on("close", (code, signal) => {
|
|
1355
|
+
if (timedOut) settle({ ok: false, error: `timed out after ${config.timeout_s} s`, stdout: "" });
|
|
1356
|
+
else if (code === 0) settle({ ok: true, code, stdout });
|
|
1357
|
+
else {
|
|
1358
|
+
const last = stderr.trim().split("\n").at(-1)?.trim();
|
|
1359
|
+
settle({ ok: false, code, error: `${code === null ? `killed by ${signal}` : `exit ${code}`}${last ? `: ${last}` : ""}`, stdout });
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
child.stdin?.on("error", () => {
|
|
1363
|
+
});
|
|
1364
|
+
child.stdin?.end(input);
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
async function run(job, config) {
|
|
1368
|
+
const started = options.now();
|
|
1369
|
+
const event = {
|
|
1370
|
+
scp: 1,
|
|
1371
|
+
event: "wake",
|
|
1372
|
+
endpoint: job.address,
|
|
1373
|
+
handle: job.handle,
|
|
1374
|
+
provider: job.provider,
|
|
1375
|
+
ref: job.ref,
|
|
1376
|
+
machine: options.machine,
|
|
1377
|
+
message: job.message,
|
|
1378
|
+
unread: job.unread
|
|
1379
|
+
};
|
|
1380
|
+
const env = {
|
|
1381
|
+
...process.env,
|
|
1382
|
+
SCP_DIR: options.dir,
|
|
1383
|
+
SCP_WAKE_ENDPOINT: job.address,
|
|
1384
|
+
SCP_WAKE_HANDLE: job.handle,
|
|
1385
|
+
SCP_WAKE_REF: job.ref ?? "",
|
|
1386
|
+
SCP_WAKE_PROVIDER: job.provider,
|
|
1387
|
+
SCP_WAKE_MESSAGE_ID: job.message.id,
|
|
1388
|
+
SCP_WAKE_FROM: job.message.from ?? "",
|
|
1389
|
+
SCP_WAKE_KIND: job.message.kind,
|
|
1390
|
+
SCP_WAKE_THREAD: job.message.thread
|
|
1391
|
+
};
|
|
1392
|
+
const result = await exec(config, `${JSON.stringify(event)}
|
|
1393
|
+
`, env);
|
|
1394
|
+
const declined = result.ok ? null : declineOf(result.code, result.stdout);
|
|
1395
|
+
finish(job, started, { ok: result.ok, code: result.code ?? null, ...declined ? { declined } : { error: result.error ?? null } });
|
|
1396
|
+
}
|
|
1397
|
+
return {
|
|
1398
|
+
known: (provider) => Object.hasOwn(configs(), provider),
|
|
1399
|
+
kick(endpoint) {
|
|
1400
|
+
try {
|
|
1401
|
+
if (stopped || busy.has(endpoint)) return;
|
|
1402
|
+
const job = options.core.wakeDue(endpoint, options.now());
|
|
1403
|
+
if (!job) return;
|
|
1404
|
+
busy.add(endpoint);
|
|
1405
|
+
const config = configs()[job.provider];
|
|
1406
|
+
if (!config) {
|
|
1407
|
+
finish(job, options.now(), { ok: false, error: `no waker ${job.provider} is configured` });
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
run(job, config).catch((error) => {
|
|
1411
|
+
busy.delete(endpoint);
|
|
1412
|
+
options.log(`relay broker: a wake hook failed: ${message(error)}`);
|
|
1413
|
+
});
|
|
1414
|
+
} catch (error) {
|
|
1415
|
+
busy.delete(endpoint);
|
|
1416
|
+
options.log(`relay broker: a wake hook did not start: ${message(error)}`);
|
|
1417
|
+
}
|
|
1418
|
+
},
|
|
1419
|
+
stop() {
|
|
1420
|
+
stopped = true;
|
|
1421
|
+
for (const child of children) kill(child);
|
|
1422
|
+
children.clear();
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
function kill(child) {
|
|
1427
|
+
try {
|
|
1428
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
1429
|
+
} catch {
|
|
1430
|
+
child.kill("SIGKILL");
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
function message(error) {
|
|
1434
|
+
return error instanceof Error ? error.message : String(error);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1226
1437
|
// src/server.ts
|
|
1227
1438
|
var SWEEP_MS = 10 * 6e4;
|
|
1228
1439
|
var PRESENCE_MS = 6e4;
|
|
1229
1440
|
var OWNERSHIP_MS = 2e3;
|
|
1441
|
+
var HIBERNATE_BARRIER_MS = 1e4;
|
|
1230
1442
|
var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"]);
|
|
1231
1443
|
var SCOPE_METHODS = /* @__PURE__ */ new Set([
|
|
1232
1444
|
"rules",
|
|
@@ -1306,6 +1518,7 @@ async function startBroker(options) {
|
|
|
1306
1518
|
let core;
|
|
1307
1519
|
let judge;
|
|
1308
1520
|
let bridge = null;
|
|
1521
|
+
let wakers = null;
|
|
1309
1522
|
let listener;
|
|
1310
1523
|
let socketIno;
|
|
1311
1524
|
try {
|
|
@@ -1388,6 +1601,7 @@ async function startBroker(options) {
|
|
|
1388
1601
|
if ((known.get(view.id) ?? "offline") !== view.presence) presenceChanged(view);
|
|
1389
1602
|
else if (wakes.has(view.id) && wakes.get(view.id) !== view.wake) bridge?.changed(view.id);
|
|
1390
1603
|
wakes.set(view.id, view.wake);
|
|
1604
|
+
if (view.presence === "hibernated") wakers?.kick(view.id);
|
|
1391
1605
|
}
|
|
1392
1606
|
for (const id of known.keys()) if (!live.has(id)) known.delete(id);
|
|
1393
1607
|
for (const id of wakes.keys()) if (!live.has(id)) wakes.delete(id);
|
|
@@ -1403,7 +1617,7 @@ async function startBroker(options) {
|
|
|
1403
1617
|
}
|
|
1404
1618
|
function ownsState() {
|
|
1405
1619
|
try {
|
|
1406
|
-
return ownsSocket() &&
|
|
1620
|
+
return ownsSocket() && readFileSync7(lock.path, "utf8") === lock.content;
|
|
1407
1621
|
} catch {
|
|
1408
1622
|
return false;
|
|
1409
1623
|
}
|
|
@@ -1419,6 +1633,16 @@ async function startBroker(options) {
|
|
|
1419
1633
|
};
|
|
1420
1634
|
if (options.bridge) bridge = startBridge({ ...options.bridge, core, onRemote: remoteHooks });
|
|
1421
1635
|
else if (options.link) bridge = options.link({ core, hooks: remoteHooks, send: (from, params, origin) => judge.send(from, params, origin, deliver) });
|
|
1636
|
+
wakers = startWakers({
|
|
1637
|
+
core,
|
|
1638
|
+
path: options.wakersPath ?? join4(dirname2(policyFile), "wakers.json"),
|
|
1639
|
+
wakers: options.wakers,
|
|
1640
|
+
dir,
|
|
1641
|
+
machine: options.machine ?? options.bridge?.machine ?? shortHostname(),
|
|
1642
|
+
now: options.now ?? Date.now,
|
|
1643
|
+
log,
|
|
1644
|
+
changed: (endpoint) => presenceChanged(core.whoami(endpoint))
|
|
1645
|
+
});
|
|
1422
1646
|
async function relink() {
|
|
1423
1647
|
const config = options.bridgeConfig ? await options.bridgeConfig() : options.bridge;
|
|
1424
1648
|
if (!config || bridge && config.url === options.bridge?.url && config.machine === options.bridge?.machine) {
|
|
@@ -1436,7 +1660,7 @@ async function startBroker(options) {
|
|
|
1436
1660
|
function loadPolicy() {
|
|
1437
1661
|
let text;
|
|
1438
1662
|
try {
|
|
1439
|
-
text =
|
|
1663
|
+
text = readFileSync7(policyFile, "utf8");
|
|
1440
1664
|
} catch (error) {
|
|
1441
1665
|
const code = error.code;
|
|
1442
1666
|
const dangling = code === "ENOENT" && lstatSync2(policyFile, { throwIfNoEntry: false }) !== void 0;
|
|
@@ -1518,7 +1742,15 @@ async function startBroker(options) {
|
|
|
1518
1742
|
}
|
|
1519
1743
|
function presenceChanged(view) {
|
|
1520
1744
|
known.set(view.id, view.presence);
|
|
1521
|
-
notifyFleet({
|
|
1745
|
+
notifyFleet({
|
|
1746
|
+
type: "presence",
|
|
1747
|
+
endpoint: view.address,
|
|
1748
|
+
presence: view.presence,
|
|
1749
|
+
harness: view.harness,
|
|
1750
|
+
labels: view.labels,
|
|
1751
|
+
...view.wake_failed ? { wake_failed: view.wake_failed } : {},
|
|
1752
|
+
...view.wake_declined ? { wake_declined: view.wake_declined } : {}
|
|
1753
|
+
});
|
|
1522
1754
|
bridge?.changed(view.id);
|
|
1523
1755
|
}
|
|
1524
1756
|
function membership(view, session, joined, was) {
|
|
@@ -1599,11 +1831,25 @@ async function startBroker(options) {
|
|
|
1599
1831
|
broker: brokerInfo()
|
|
1600
1832
|
};
|
|
1601
1833
|
}
|
|
1834
|
+
async function mirrorBarrier(endpoints) {
|
|
1835
|
+
if (!bridge?.mirrorNow || !endpoints.length) return false;
|
|
1836
|
+
let timer;
|
|
1837
|
+
const timeout = new Promise((resolve) => {
|
|
1838
|
+
timer = setTimeout(() => resolve(false), HIBERNATE_BARRIER_MS);
|
|
1839
|
+
});
|
|
1840
|
+
try {
|
|
1841
|
+
return await Promise.race([bridge.mirrorNow(endpoints), timeout]);
|
|
1842
|
+
} finally {
|
|
1843
|
+
clearTimeout(timer);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1602
1846
|
function wake(endpoint) {
|
|
1603
1847
|
const set = waiters.get(endpoint);
|
|
1604
|
-
if (
|
|
1605
|
-
|
|
1606
|
-
|
|
1848
|
+
if (set) {
|
|
1849
|
+
waiters.delete(endpoint);
|
|
1850
|
+
for (const fn of set) fn();
|
|
1851
|
+
}
|
|
1852
|
+
wakers?.kick(endpoint);
|
|
1607
1853
|
}
|
|
1608
1854
|
function waitFor(endpoint, ms, s) {
|
|
1609
1855
|
return new Promise((resolve) => {
|
|
@@ -1702,8 +1948,8 @@ async function startBroker(options) {
|
|
|
1702
1948
|
pump(s);
|
|
1703
1949
|
}
|
|
1704
1950
|
}
|
|
1705
|
-
function invalidRequest(s, id,
|
|
1706
|
-
write(s, { jsonrpc: "2.0", id, error: { code: -32600, message, data: { code: "invalid_params" } } });
|
|
1951
|
+
function invalidRequest(s, id, message2) {
|
|
1952
|
+
write(s, { jsonrpc: "2.0", id, error: { code: -32600, message: message2, data: { code: "invalid_params" } } });
|
|
1707
1953
|
}
|
|
1708
1954
|
async function answer(s, line) {
|
|
1709
1955
|
if (closing) return;
|
|
@@ -2003,6 +2249,23 @@ async function startBroker(options) {
|
|
|
2003
2249
|
bridge?.resync();
|
|
2004
2250
|
return unlinked;
|
|
2005
2251
|
}
|
|
2252
|
+
case "hibernate": {
|
|
2253
|
+
const known2 = { known: (provider) => wakers?.known(provider) ?? false };
|
|
2254
|
+
const set = { wake: params.wake, ref: params.ref, eta_s: params.eta_s };
|
|
2255
|
+
if (params.all !== void 0 && params.all !== true) throw new RelayError3("invalid_params", "all is true or absent");
|
|
2256
|
+
if (params.all === true && params.endpoint !== void 0) throw new RelayError3("invalid_params", "all names no endpoint");
|
|
2257
|
+
const result = params.all === true ? core.hibernateAll(actorOf(s), set, known2) : core.hibernate(actorOf(s), { ...params.endpoint !== void 0 ? { endpoint: params.endpoint } : {}, ...set }, known2);
|
|
2258
|
+
const views = "endpoints" in result ? result.endpoints : [result.endpoint];
|
|
2259
|
+
for (const view of views) presenceChanged(view);
|
|
2260
|
+
return { ...result, mirrored: await mirrorBarrier(views.map((view) => view.id)) };
|
|
2261
|
+
}
|
|
2262
|
+
case "machine_wake": {
|
|
2263
|
+
if (actorOf(s).kind !== "human") throw new RelayError3("unauthorized", "only the human sets this machine's waker");
|
|
2264
|
+
if (!bridge?.machineWake) throw new RelayError3("unavailable", "this broker has no hub link: the machine waker lives on the hub");
|
|
2265
|
+
if (params.url !== null && typeof params.url !== "string") throw new RelayError3("invalid_params", "url is an https URL, or null to clear it");
|
|
2266
|
+
if (params.secret !== void 0 && typeof params.secret !== "string") throw new RelayError3("invalid_params", "secret is a string");
|
|
2267
|
+
return bridge.machineWake({ url: params.url, ...params.secret !== void 0 ? { secret: params.secret } : {} });
|
|
2268
|
+
}
|
|
2006
2269
|
case "leave": {
|
|
2007
2270
|
const left = core.leaveSession(actorOf(s), params.endpoint !== void 0 ? { endpoint: params.endpoint } : {});
|
|
2008
2271
|
for (const c of connections) if (c.data.endpoint === left.endpoint.id && !c.data.transient && !c.data.lineage && c !== s) c.end();
|
|
@@ -2225,24 +2488,24 @@ async function startBroker(options) {
|
|
|
2225
2488
|
})
|
|
2226
2489
|
);
|
|
2227
2490
|
if (result.handback) {
|
|
2228
|
-
const
|
|
2491
|
+
const message2 = core.delivered(spawner, result.handback);
|
|
2229
2492
|
wake(spawner);
|
|
2230
2493
|
bridge?.local?.({
|
|
2231
|
-
id:
|
|
2232
|
-
thread:
|
|
2494
|
+
id: message2.id,
|
|
2495
|
+
thread: message2.thread,
|
|
2233
2496
|
kind: "handback",
|
|
2234
|
-
from:
|
|
2497
|
+
from: message2.from.endpoint,
|
|
2235
2498
|
to: [spawner],
|
|
2236
2499
|
at: (options.now ?? Date.now)()
|
|
2237
2500
|
});
|
|
2238
2501
|
notifyFleet({
|
|
2239
2502
|
type: "message",
|
|
2240
|
-
id:
|
|
2241
|
-
thread:
|
|
2242
|
-
kind:
|
|
2243
|
-
from:
|
|
2244
|
-
to: [
|
|
2245
|
-
hop:
|
|
2503
|
+
id: message2.id,
|
|
2504
|
+
thread: message2.thread,
|
|
2505
|
+
kind: message2.kind,
|
|
2506
|
+
from: message2.from.address,
|
|
2507
|
+
to: [message2.to],
|
|
2508
|
+
hop: message2.hop
|
|
2246
2509
|
});
|
|
2247
2510
|
}
|
|
2248
2511
|
if (result.endpoint) {
|
|
@@ -2286,6 +2549,7 @@ async function startBroker(options) {
|
|
|
2286
2549
|
clearInterval(sweeper);
|
|
2287
2550
|
clearInterval(presenceTimer);
|
|
2288
2551
|
clearInterval(ownership);
|
|
2552
|
+
wakers?.stop();
|
|
2289
2553
|
await bridge?.stop();
|
|
2290
2554
|
if (idleTimer) clearTimeout(idleTimer);
|
|
2291
2555
|
for (const set of [...waiters.values()]) for (const fn of [...set]) fn();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scp-dev/broker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Swarm Communication Protocol local broker: a unix socket JSON-RPC daemon over the relay core, its client and the bridge to an account hub.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://scp.dev",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"typecheck": "tsc -p ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@scp-dev/core": "0.
|
|
41
|
-
"@scp-dev/hub": "0.
|
|
40
|
+
"@scp-dev/core": "0.5.0",
|
|
41
|
+
"@scp-dev/hub": "0.7.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"tsup": "^8.5.1"
|
package/src/bridge.ts
CHANGED
|
@@ -76,6 +76,8 @@ export interface Bridge {
|
|
|
76
76
|
forward(from: string, params: SendParams, origin?: { tty?: boolean }): Promise<SendResult>
|
|
77
77
|
/** a local endpoint was claimed, let go or changed its status: mirrored as a delta */
|
|
78
78
|
changed(endpoint: string): void
|
|
79
|
+
/** mirrors these endpoints now and resolves true once the hub took the delta, false when it did not or none is mirrored */
|
|
80
|
+
mirrorNow?(endpoints: string[]): Promise<boolean>
|
|
79
81
|
/** rules, caps, groups or a title changed, which can touch several endpoints: every mirrored endpoint is sent again */
|
|
80
82
|
resync(): void
|
|
81
83
|
/** a local endpoint took another handle: the hub renames its mirror in place, keeping what queued for it */
|
|
@@ -86,6 +88,8 @@ export interface Bridge {
|
|
|
86
88
|
local?(event: LocalDelivery): void
|
|
87
89
|
/** the endpoints of other accounts this account's contacts let it reach */
|
|
88
90
|
contacts(): Promise<XListed[]>
|
|
91
|
+
/** sets (or with a null url clears) the URL the hub calls to wake this machine; the secret signs those calls and is never shown again */
|
|
92
|
+
machineWake?(params: { url: string | null; secret?: string }): Promise<{ set: boolean }>
|
|
89
93
|
status(): BridgeStatus
|
|
90
94
|
/** reads the credential again and reconnects now, also after the hub revoked or replaced this bridge */
|
|
91
95
|
relink(): void
|
|
@@ -102,6 +106,9 @@ export interface LocalDelivery {
|
|
|
102
106
|
at: number
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
/** the endpoints one mirrorNow frame carries */
|
|
110
|
+
const MIRROR_NOW_BATCH = 32
|
|
111
|
+
|
|
105
112
|
/** local deliveries waiting for the next batch; past this, new ones are dropped until it is sent */
|
|
106
113
|
const LOCAL_BUFFER = 1000
|
|
107
114
|
|
|
@@ -639,6 +646,25 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
639
646
|
if (mirrored(view)) fireAndForget("mirror", { endpoints: [outward(view)], full: false })
|
|
640
647
|
}
|
|
641
648
|
|
|
649
|
+
async function mirrorNow(endpoints: string[]): Promise<boolean> {
|
|
650
|
+
if (!ready) return false
|
|
651
|
+
const views = endpoints.flatMap((id) => {
|
|
652
|
+
try {
|
|
653
|
+
const view = core.whoami(id)
|
|
654
|
+
return mirrored(view) ? [outward(view)] : []
|
|
655
|
+
} catch {
|
|
656
|
+
return []
|
|
657
|
+
}
|
|
658
|
+
})
|
|
659
|
+
if (!views.length) return false
|
|
660
|
+
try {
|
|
661
|
+
for (let i = 0; i < views.length; i += MIRROR_NOW_BATCH) await request("mirror", { endpoints: views.slice(i, i + MIRROR_NOW_BATCH), full: false })
|
|
662
|
+
return true
|
|
663
|
+
} catch {
|
|
664
|
+
return false
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
642
668
|
function renamed(endpoint: string, was: string) {
|
|
643
669
|
if (!ready) return
|
|
644
670
|
let view: EndpointView
|
|
@@ -700,6 +726,11 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
700
726
|
return (await request<{ contacts: XListed[] }>("list", { contacts: true })).contacts
|
|
701
727
|
}
|
|
702
728
|
|
|
729
|
+
async function machineWake(params: { url: string | null; secret?: string }): Promise<{ set: boolean }> {
|
|
730
|
+
if (!connected) throw unavailable()
|
|
731
|
+
return request<{ set: boolean }>("machine_wake", params)
|
|
732
|
+
}
|
|
733
|
+
|
|
703
734
|
function status(): BridgeStatus {
|
|
704
735
|
return { enabled: true, machine, url: options.url, connected, last_error: lastError, since }
|
|
705
736
|
}
|
|
@@ -732,5 +763,5 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
732
763
|
}
|
|
733
764
|
|
|
734
765
|
connect()
|
|
735
|
-
return { forward, changed, resync, renamed, activity, local, contacts, status, relink, stop }
|
|
766
|
+
return { forward, changed, mirrorNow, resync, renamed, activity, local, contacts, machineWake, status, relink, stop }
|
|
736
767
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reference wake hook for tests: it stands in for a provider that resumes a stopped harness on this machine. It reads the wake event on
|
|
3
|
+
* stdin, claims the endpoint again as the resumed session would (`SCP_WAKE_HANDLE`, against the broker in `SCP_DIR`), drains and acks its
|
|
4
|
+
* inbox, stays up briefly and exits 0. Usage: bun local-sleep-waker.ts <harness> [<file to append the event and the read ids to>]
|
|
5
|
+
*/
|
|
6
|
+
import { appendFileSync } from "node:fs"
|
|
7
|
+
import { setTimeout as sleep } from "node:timers/promises"
|
|
8
|
+
import type { Envelope } from "@scp-dev/core"
|
|
9
|
+
import { RelayClient } from "./client"
|
|
10
|
+
import { socketPath } from "./paths"
|
|
11
|
+
|
|
12
|
+
const [harness = "cli", record] = process.argv.slice(2)
|
|
13
|
+
let event = ""
|
|
14
|
+
for await (const chunk of process.stdin) event += String(chunk)
|
|
15
|
+
const dir = process.env.SCP_DIR
|
|
16
|
+
const handle = process.env.SCP_WAKE_HANDLE
|
|
17
|
+
if (!dir || !handle) {
|
|
18
|
+
console.error("local-sleep-waker: SCP_DIR and SCP_WAKE_HANDLE are required")
|
|
19
|
+
process.exit(2)
|
|
20
|
+
}
|
|
21
|
+
if (record) appendFileSync(record, event)
|
|
22
|
+
const client = await RelayClient.connect(socketPath(dir))
|
|
23
|
+
await client.hello({ claim: { handle, harness } })
|
|
24
|
+
const { messages } = await client.request<{ messages: Envelope[] }>("inbox", {})
|
|
25
|
+
const seqs = messages.flatMap((m) => (m.seq === undefined ? [] : [m.seq]))
|
|
26
|
+
if (seqs.length) await client.request("ack", { seqs })
|
|
27
|
+
if (record) appendFileSync(record, `${JSON.stringify({ read: messages.map((m) => m.id) })}\n`)
|
|
28
|
+
await sleep(200)
|
|
29
|
+
client.close()
|
package/src/server.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { accountHome, accountHomeVerified, policyPath as defaultPolicyPath, priv
|
|
|
34
34
|
import { writeSessionMarker } from "./sessions"
|
|
35
35
|
import { Conn } from "./socket"
|
|
36
36
|
import { type Database, openDatabase } from "./sqlite"
|
|
37
|
+
import { startWakers, type Wakers } from "./wakers"
|
|
37
38
|
|
|
38
39
|
export interface BrokerOptions {
|
|
39
40
|
dir: string
|
|
@@ -70,6 +71,10 @@ export interface BrokerOptions {
|
|
|
70
71
|
env?: Record<string, string | undefined> | undefined
|
|
71
72
|
/** the judge model client for a policy's `judge.model` (default the provider's HTTP API) */
|
|
72
73
|
judgeClient?: JudgeClientFactory | undefined
|
|
74
|
+
/** the wake hooks' configuration file (default wakers.json beside the policy file), read again when it changes */
|
|
75
|
+
wakersPath?: string | undefined
|
|
76
|
+
/** the wake hooks by provider, in place of the file */
|
|
77
|
+
wakers?: Record<string, unknown> | undefined
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
/** what a link gets from the broker: its core, the remote hooks a bridge gets, and a send as a local endpoint or a mirrored one through the judge */
|
|
@@ -131,6 +136,8 @@ interface Connection {
|
|
|
131
136
|
const SWEEP_MS = 10 * 60_000
|
|
132
137
|
const PRESENCE_MS = 60_000
|
|
133
138
|
const OWNERSHIP_MS = 2_000
|
|
139
|
+
/** how long a hibernate waits for the hub to take its mirror */
|
|
140
|
+
const HIBERNATE_BARRIER_MS = 10_000
|
|
134
141
|
const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"])
|
|
135
142
|
const SCOPE_METHODS = new Set([
|
|
136
143
|
"rules",
|
|
@@ -223,6 +230,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
223
230
|
let core!: FederatedCore
|
|
224
231
|
let judge!: JudgeGate
|
|
225
232
|
let bridge: Bridge | null = null
|
|
233
|
+
let wakers: Wakers | null = null
|
|
226
234
|
let listener!: Server
|
|
227
235
|
let socketIno: number | bigint | undefined
|
|
228
236
|
try {
|
|
@@ -305,6 +313,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
305
313
|
if ((known.get(view.id) ?? "offline") !== view.presence) presenceChanged(view)
|
|
306
314
|
else if (wakes.has(view.id) && wakes.get(view.id) !== view.wake) bridge?.changed(view.id)
|
|
307
315
|
wakes.set(view.id, view.wake)
|
|
316
|
+
if (view.presence === "hibernated") wakers?.kick(view.id)
|
|
308
317
|
}
|
|
309
318
|
for (const id of known.keys()) if (!live.has(id)) known.delete(id)
|
|
310
319
|
for (const id of wakes.keys()) if (!live.has(id)) wakes.delete(id)
|
|
@@ -340,6 +349,16 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
340
349
|
}
|
|
341
350
|
if (options.bridge) bridge = startBridge({ ...options.bridge, core, onRemote: remoteHooks })
|
|
342
351
|
else if (options.link) bridge = options.link({ core, hooks: remoteHooks, send: (from, params, origin) => judge.send(from, params, origin, deliver) })
|
|
352
|
+
wakers = startWakers({
|
|
353
|
+
core,
|
|
354
|
+
path: options.wakersPath ?? join(dirname(policyFile), "wakers.json"),
|
|
355
|
+
wakers: options.wakers,
|
|
356
|
+
dir,
|
|
357
|
+
machine: options.machine ?? options.bridge?.machine ?? shortHostname(),
|
|
358
|
+
now: options.now ?? Date.now,
|
|
359
|
+
log,
|
|
360
|
+
changed: (endpoint) => presenceChanged(core.whoami(endpoint)),
|
|
361
|
+
})
|
|
343
362
|
|
|
344
363
|
/** links the bridge again with the credential on disk; a configuration this broker cannot take without a restart stops it */
|
|
345
364
|
async function relink(): Promise<object> {
|
|
@@ -461,7 +480,15 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
461
480
|
/** an endpoint's presence changed outside a connection's own hello or close: watchers and the other machines hear it */
|
|
462
481
|
function presenceChanged(view: EndpointView) {
|
|
463
482
|
known.set(view.id, view.presence)
|
|
464
|
-
notifyFleet({
|
|
483
|
+
notifyFleet({
|
|
484
|
+
type: "presence",
|
|
485
|
+
endpoint: view.address,
|
|
486
|
+
presence: view.presence,
|
|
487
|
+
harness: view.harness,
|
|
488
|
+
labels: view.labels,
|
|
489
|
+
...(view.wake_failed ? { wake_failed: view.wake_failed } : {}),
|
|
490
|
+
...(view.wake_declined ? { wake_declined: view.wake_declined } : {}),
|
|
491
|
+
})
|
|
465
492
|
bridge?.changed(view.id)
|
|
466
493
|
}
|
|
467
494
|
|
|
@@ -558,11 +585,28 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
558
585
|
}
|
|
559
586
|
}
|
|
560
587
|
|
|
588
|
+
/** whether the hub took these endpoints' mirror within HIBERNATE_BARRIER_MS, so the machine may be suspended at once */
|
|
589
|
+
async function mirrorBarrier(endpoints: string[]): Promise<boolean> {
|
|
590
|
+
if (!bridge?.mirrorNow || !endpoints.length) return false
|
|
591
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
592
|
+
const timeout = new Promise<boolean>((resolve) => {
|
|
593
|
+
timer = setTimeout(() => resolve(false), HIBERNATE_BARRIER_MS)
|
|
594
|
+
})
|
|
595
|
+
try {
|
|
596
|
+
return await Promise.race([bridge.mirrorNow(endpoints), timeout])
|
|
597
|
+
} finally {
|
|
598
|
+
clearTimeout(timer)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** a delivery reached `endpoint`: its waiters return, and a hibernated one's wake hook runs when due */
|
|
561
603
|
function wake(endpoint: string) {
|
|
562
604
|
const set = waiters.get(endpoint)
|
|
563
|
-
if (
|
|
564
|
-
|
|
565
|
-
|
|
605
|
+
if (set) {
|
|
606
|
+
waiters.delete(endpoint)
|
|
607
|
+
for (const fn of set) fn()
|
|
608
|
+
}
|
|
609
|
+
wakers?.kick(endpoint)
|
|
566
610
|
}
|
|
567
611
|
|
|
568
612
|
function waitFor(endpoint: string, ms: number, s: Conn<Connection>): Promise<void> {
|
|
@@ -993,6 +1037,26 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
993
1037
|
bridge?.resync()
|
|
994
1038
|
return unlinked
|
|
995
1039
|
}
|
|
1040
|
+
case "hibernate": {
|
|
1041
|
+
const known = { known: (provider: string) => wakers?.known(provider) ?? false }
|
|
1042
|
+
const set = { wake: params.wake, ref: params.ref, eta_s: params.eta_s }
|
|
1043
|
+
if (params.all !== undefined && params.all !== true) throw new RelayError("invalid_params", "all is true or absent")
|
|
1044
|
+
if (params.all === true && params.endpoint !== undefined) throw new RelayError("invalid_params", "all names no endpoint")
|
|
1045
|
+
const result =
|
|
1046
|
+
params.all === true
|
|
1047
|
+
? core.hibernateAll(actorOf(s), set, known)
|
|
1048
|
+
: core.hibernate(actorOf(s), { ...(params.endpoint !== undefined ? { endpoint: params.endpoint } : {}), ...set }, known)
|
|
1049
|
+
const views = "endpoints" in result ? result.endpoints : [result.endpoint]
|
|
1050
|
+
for (const view of views) presenceChanged(view)
|
|
1051
|
+
return { ...result, mirrored: await mirrorBarrier(views.map((view) => view.id)) }
|
|
1052
|
+
}
|
|
1053
|
+
case "machine_wake": {
|
|
1054
|
+
if (actorOf(s).kind !== "human") throw new RelayError("unauthorized", "only the human sets this machine's waker")
|
|
1055
|
+
if (!bridge?.machineWake) throw new RelayError("unavailable", "this broker has no hub link: the machine waker lives on the hub")
|
|
1056
|
+
if (params.url !== null && typeof params.url !== "string") throw new RelayError("invalid_params", "url is an https URL, or null to clear it")
|
|
1057
|
+
if (params.secret !== undefined && typeof params.secret !== "string") throw new RelayError("invalid_params", "secret is a string")
|
|
1058
|
+
return bridge.machineWake({ url: params.url, ...(params.secret !== undefined ? { secret: params.secret } : {}) })
|
|
1059
|
+
}
|
|
996
1060
|
case "leave": {
|
|
997
1061
|
const left = core.leaveSession(actorOf(s), params.endpoint !== undefined ? { endpoint: params.endpoint } : {})
|
|
998
1062
|
for (const c of connections) if (c.data.endpoint === left.endpoint.id && !c.data.transient && !c.data.lineage && c !== s) c.end()
|
|
@@ -1292,6 +1356,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
1292
1356
|
clearInterval(sweeper)
|
|
1293
1357
|
clearInterval(presenceTimer)
|
|
1294
1358
|
clearInterval(ownership)
|
|
1359
|
+
wakers?.stop()
|
|
1295
1360
|
await bridge?.stop()
|
|
1296
1361
|
if (idleTimer) clearTimeout(idleTimer)
|
|
1297
1362
|
for (const set of [...waiters.values()]) for (const fn of [...set]) fn()
|
package/src/wakers.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process"
|
|
2
|
+
import { readFileSync, statSync } from "node:fs"
|
|
3
|
+
import { WAKE_PROVIDER, type WakeDecline, type WakeJob, wakeDeclineOf } from "@scp-dev/core"
|
|
4
|
+
import type { FederatedCore } from "@scp-dev/hub"
|
|
5
|
+
|
|
6
|
+
export interface WakerConfig {
|
|
7
|
+
command: string[]
|
|
8
|
+
timeout_s: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type WakerConfigs = Record<string, WakerConfig>
|
|
12
|
+
|
|
13
|
+
const TIMEOUT_DEFAULT_S = 60
|
|
14
|
+
const TIMEOUT_MAX_S = 3600
|
|
15
|
+
const STDERR_KEEP = 1024
|
|
16
|
+
const STDOUT_KEEP = 4096
|
|
17
|
+
/** a waker's exit code for a decline, with `{"declined": "<reason>"}` on stdout */
|
|
18
|
+
export const WAKE_DECLINED_EXIT = 77
|
|
19
|
+
|
|
20
|
+
/** the decline a waker's exit 77 and stdout carry, or null */
|
|
21
|
+
function declineOf(code: number | null | undefined, stdout: string): WakeDecline | null {
|
|
22
|
+
if (code !== WAKE_DECLINED_EXIT) return null
|
|
23
|
+
try {
|
|
24
|
+
return wakeDeclineOf((JSON.parse(stdout) as { declined?: unknown }).declined)
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** a wakers.json object: `{ "<provider>": { "command": [argv...], "timeout_s"?: N } }`, other fields ignored; throws on anything else */
|
|
31
|
+
export function parseWakers(value: unknown): WakerConfigs {
|
|
32
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("wakers is an object of provider names")
|
|
33
|
+
const out: WakerConfigs = {}
|
|
34
|
+
for (const [provider, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
35
|
+
if (!WAKE_PROVIDER.test(provider)) throw new Error(`${JSON.stringify(provider)} is not a provider name`)
|
|
36
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error(`waker ${provider} is not an object`)
|
|
37
|
+
const { command, timeout_s } = entry as { command?: unknown; timeout_s?: unknown }
|
|
38
|
+
if (!Array.isArray(command) || !command.length || !command.every((part) => typeof part === "string" && part.length > 0))
|
|
39
|
+
throw new Error(`waker ${provider}: command is a nonempty array of strings`)
|
|
40
|
+
const timeout = timeout_s ?? TIMEOUT_DEFAULT_S
|
|
41
|
+
if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 1 || timeout > TIMEOUT_MAX_S)
|
|
42
|
+
throw new Error(`waker ${provider}: timeout_s is a whole number of seconds from 1 to ${TIMEOUT_MAX_S}`)
|
|
43
|
+
out[provider] = { command: command as string[], timeout_s: timeout }
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Wakers {
|
|
49
|
+
/** whether a waker is configured for `provider` */
|
|
50
|
+
known(provider: string): boolean
|
|
51
|
+
/** runs the endpoint's wake hook when one is due; never throws, never waits */
|
|
52
|
+
kick(endpoint: string): void
|
|
53
|
+
/** kills the hooks still running */
|
|
54
|
+
stop(): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type RunResult = { ok: boolean; code?: number | null; error?: string; stdout: string }
|
|
58
|
+
type Outcome = { ok: boolean; code?: number | null; error?: string | null; declined?: WakeDecline }
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* the broker's wake hooks: `wakers` as given, else the file at `path`, read again when its mtime changes. A malformed file is logged once
|
|
62
|
+
* and configures nothing. At most one hook runs per endpoint; `changed` hears an endpoint whose shown wake failure changed
|
|
63
|
+
*/
|
|
64
|
+
export function startWakers(options: {
|
|
65
|
+
core: FederatedCore
|
|
66
|
+
path: string
|
|
67
|
+
wakers?: Record<string, unknown> | undefined
|
|
68
|
+
dir: string
|
|
69
|
+
machine: string
|
|
70
|
+
now: () => number
|
|
71
|
+
log: (line: string) => void
|
|
72
|
+
changed: (endpoint: string) => void
|
|
73
|
+
}): Wakers {
|
|
74
|
+
const busy = new Set<string>()
|
|
75
|
+
const children = new Set<ChildProcess>()
|
|
76
|
+
let stopped = false
|
|
77
|
+
let fixed: WakerConfigs | null = null
|
|
78
|
+
if (options.wakers !== undefined) {
|
|
79
|
+
try {
|
|
80
|
+
fixed = parseWakers(options.wakers)
|
|
81
|
+
} catch (error) {
|
|
82
|
+
options.log(`relay broker: the wakers are invalid (${message(error)}); no wake hook runs`)
|
|
83
|
+
fixed = {}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
let loaded: { mtime: number; size: number; wakers: WakerConfigs } | null = null
|
|
87
|
+
|
|
88
|
+
function configs(): WakerConfigs {
|
|
89
|
+
if (fixed) return fixed
|
|
90
|
+
const stat = statSync(options.path, { throwIfNoEntry: false })
|
|
91
|
+
if (!stat) {
|
|
92
|
+
loaded = null
|
|
93
|
+
return {}
|
|
94
|
+
}
|
|
95
|
+
if (loaded && loaded.mtime === stat.mtimeMs && loaded.size === stat.size) return loaded.wakers
|
|
96
|
+
let wakers: WakerConfigs = {}
|
|
97
|
+
try {
|
|
98
|
+
wakers = parseWakers(JSON.parse(readFileSync(options.path, "utf8")))
|
|
99
|
+
} catch (error) {
|
|
100
|
+
options.log(`relay broker: the wakers at ${options.path} are invalid (${message(error)}); no wake hook runs until it is fixed`)
|
|
101
|
+
}
|
|
102
|
+
loaded = { mtime: stat.mtimeMs, size: stat.size, wakers }
|
|
103
|
+
return wakers
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function finish(job: WakeJob, started: number, result: Outcome) {
|
|
107
|
+
busy.delete(job.endpoint)
|
|
108
|
+
if (stopped) return
|
|
109
|
+
try {
|
|
110
|
+
if (options.core.wakeResult(job.endpoint, { ...result, ms: options.now() - started, message: job.message.id })) options.changed(job.endpoint)
|
|
111
|
+
} catch (error) {
|
|
112
|
+
options.log(`relay broker: a wake result was not recorded: ${message(error)}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** runs the waker with stdin `input`, keeping the head of its stdout for a decline */
|
|
117
|
+
function exec(config: WakerConfig, input: string, env: Record<string, string | undefined>): Promise<RunResult> {
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
let child: ChildProcess
|
|
120
|
+
try {
|
|
121
|
+
child = spawn(config.command[0]!, config.command.slice(1), { stdio: ["pipe", "pipe", "pipe"], env, detached: true })
|
|
122
|
+
} catch (error) {
|
|
123
|
+
resolve({ ok: false, error: message(error), stdout: "" })
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
children.add(child)
|
|
127
|
+
let stdout = ""
|
|
128
|
+
let stderr = ""
|
|
129
|
+
let settled = false
|
|
130
|
+
let timedOut = false
|
|
131
|
+
const timer = setTimeout(() => {
|
|
132
|
+
timedOut = true
|
|
133
|
+
kill(child)
|
|
134
|
+
}, config.timeout_s * 1000)
|
|
135
|
+
const settle = (result: RunResult) => {
|
|
136
|
+
if (settled) return
|
|
137
|
+
settled = true
|
|
138
|
+
clearTimeout(timer)
|
|
139
|
+
children.delete(child)
|
|
140
|
+
resolve(result)
|
|
141
|
+
}
|
|
142
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
143
|
+
if (stdout.length < STDOUT_KEEP) stdout += chunk.toString("utf8").slice(0, STDOUT_KEEP - stdout.length)
|
|
144
|
+
})
|
|
145
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
146
|
+
if (stderr.length < STDERR_KEEP) stderr += chunk.toString("utf8").slice(0, STDERR_KEEP - stderr.length)
|
|
147
|
+
})
|
|
148
|
+
child.on("error", (error) => settle({ ok: false, error: message(error), stdout: "" }))
|
|
149
|
+
child.on("close", (code, signal) => {
|
|
150
|
+
if (timedOut) settle({ ok: false, error: `timed out after ${config.timeout_s} s`, stdout: "" })
|
|
151
|
+
else if (code === 0) settle({ ok: true, code, stdout })
|
|
152
|
+
else {
|
|
153
|
+
const last = stderr.trim().split("\n").at(-1)?.trim()
|
|
154
|
+
settle({ ok: false, code, error: `${code === null ? `killed by ${signal}` : `exit ${code}`}${last ? `: ${last}` : ""}`, stdout })
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
child.stdin?.on("error", () => {})
|
|
158
|
+
child.stdin?.end(input)
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function run(job: WakeJob, config: WakerConfig) {
|
|
163
|
+
const started = options.now()
|
|
164
|
+
const event = {
|
|
165
|
+
scp: 1,
|
|
166
|
+
event: "wake",
|
|
167
|
+
endpoint: job.address,
|
|
168
|
+
handle: job.handle,
|
|
169
|
+
provider: job.provider,
|
|
170
|
+
ref: job.ref,
|
|
171
|
+
machine: options.machine,
|
|
172
|
+
message: job.message,
|
|
173
|
+
unread: job.unread,
|
|
174
|
+
}
|
|
175
|
+
const env: Record<string, string | undefined> = {
|
|
176
|
+
...process.env,
|
|
177
|
+
SCP_DIR: options.dir,
|
|
178
|
+
SCP_WAKE_ENDPOINT: job.address,
|
|
179
|
+
SCP_WAKE_HANDLE: job.handle,
|
|
180
|
+
SCP_WAKE_REF: job.ref ?? "",
|
|
181
|
+
SCP_WAKE_PROVIDER: job.provider,
|
|
182
|
+
SCP_WAKE_MESSAGE_ID: job.message.id,
|
|
183
|
+
SCP_WAKE_FROM: job.message.from ?? "",
|
|
184
|
+
SCP_WAKE_KIND: job.message.kind,
|
|
185
|
+
SCP_WAKE_THREAD: job.message.thread,
|
|
186
|
+
}
|
|
187
|
+
const result = await exec(config, `${JSON.stringify(event)}\n`, env)
|
|
188
|
+
const declined = result.ok ? null : declineOf(result.code, result.stdout)
|
|
189
|
+
finish(job, started, { ok: result.ok, code: result.code ?? null, ...(declined ? { declined } : { error: result.error ?? null }) })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
known: (provider) => Object.hasOwn(configs(), provider),
|
|
194
|
+
kick(endpoint) {
|
|
195
|
+
try {
|
|
196
|
+
if (stopped || busy.has(endpoint)) return
|
|
197
|
+
const job = options.core.wakeDue(endpoint, options.now())
|
|
198
|
+
if (!job) return
|
|
199
|
+
busy.add(endpoint)
|
|
200
|
+
const config = configs()[job.provider]
|
|
201
|
+
if (!config) {
|
|
202
|
+
finish(job, options.now(), { ok: false, error: `no waker ${job.provider} is configured` })
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
run(job, config).catch((error: unknown) => {
|
|
206
|
+
busy.delete(endpoint)
|
|
207
|
+
options.log(`relay broker: a wake hook failed: ${message(error)}`)
|
|
208
|
+
})
|
|
209
|
+
} catch (error) {
|
|
210
|
+
busy.delete(endpoint)
|
|
211
|
+
options.log(`relay broker: a wake hook did not start: ${message(error)}`)
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
stop() {
|
|
215
|
+
stopped = true
|
|
216
|
+
for (const child of children) kill(child)
|
|
217
|
+
children.clear()
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function kill(child: ChildProcess) {
|
|
223
|
+
try {
|
|
224
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL")
|
|
225
|
+
} catch {
|
|
226
|
+
child.kill("SIGKILL")
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function message(error: unknown): string {
|
|
231
|
+
return error instanceof Error ? error.message : String(error)
|
|
232
|
+
}
|