@scp-dev/hub 0.6.2 → 0.7.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 +56 -4
- package/dist/index.js +151 -8
- package/package.json +2 -2
- package/src/federation.ts +18 -5
- package/src/hub.ts +168 -2
- package/src/index.ts +1 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as _scp_dev_core from '@scp-dev/core';
|
|
2
|
-
import { Envelope, RelayCore, CoreOptions, EndpointRow, EndpointView, PolicySubject, ScopeSubject, ScopeNode, ScopeDirection, Admission, MessageRow, ScopeCap, ScopeEntry, Claim, SendParams, SendResult, EndpointKind, Presence, StatusState, Verdict, JudgeModelConfig, JudgeClient, Sql, JudgePolicy, Wake, CrossAccountLink, XDelivered, XListed, ActivityRow, XRelease } from '@scp-dev/core';
|
|
2
|
+
import { Envelope, RelayCore, CoreOptions, EndpointRow, EndpointView, PolicySubject, ScopeSubject, ScopeNode, ScopeDirection, Admission, MessageRow, ScopeCap, ScopeEntry, Claim, SendParams, SendResult, EndpointKind, Presence, StatusState, Verdict, JudgeModelConfig, JudgeClient, Sql, JudgePolicy, Wake, CrossAccountLink, WakeDecline, XDelivered, XListed, ActivityRow, XRelease } from '@scp-dev/core';
|
|
3
3
|
|
|
4
4
|
type RelayAuth = Envelope["from"]["auth"];
|
|
5
5
|
/** a machine name: one relay host of an account */
|
|
@@ -175,8 +175,12 @@ declare class FederatedCore extends RelayCore {
|
|
|
175
175
|
protected renamedHandle(from: string, to: string): void;
|
|
176
176
|
protected linkedAcross(a: EndpointRow, b: EndpointRow): boolean;
|
|
177
177
|
protected linkedParent(row: EndpointRow, shown?: Set<string>): string | null;
|
|
178
|
-
/**
|
|
178
|
+
/**
|
|
179
|
+
* a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here; a hibernated one stays hibernated with
|
|
180
|
+
* its hook wake after its bridge closed
|
|
181
|
+
*/
|
|
179
182
|
protected view(row: EndpointRow, shown?: Set<string>): EndpointView;
|
|
183
|
+
protected hibernated(row: EndpointRow): boolean;
|
|
180
184
|
protected address(row: EndpointRow): string;
|
|
181
185
|
protected subject(row: EndpointRow): PolicySubject;
|
|
182
186
|
/** a stand-in for another account's sender is no endpoint of this relay: list, peers and snapshots leave it out */
|
|
@@ -575,6 +579,8 @@ interface MachineRow {
|
|
|
575
579
|
bridge: boolean;
|
|
576
580
|
created_at: string | null;
|
|
577
581
|
last_seen: string | null;
|
|
582
|
+
/** a machine waker URL is set; the URL and its secret are never shown */
|
|
583
|
+
wake?: boolean;
|
|
578
584
|
}
|
|
579
585
|
/** an endpoint as the dashboard graph draws it */
|
|
580
586
|
interface GraphNode {
|
|
@@ -640,6 +646,24 @@ interface HubSocket {
|
|
|
640
646
|
send(text: string): void;
|
|
641
647
|
close(code: number, reason: string): void;
|
|
642
648
|
}
|
|
649
|
+
/** what a machine waker gets: who to wake and the message's metadata, never its body */
|
|
650
|
+
interface MachineWakeEvent {
|
|
651
|
+
scp: 1;
|
|
652
|
+
event: "machine_wake";
|
|
653
|
+
account: string;
|
|
654
|
+
machine: string;
|
|
655
|
+
endpoints: string[];
|
|
656
|
+
message: {
|
|
657
|
+
id: string;
|
|
658
|
+
from: string;
|
|
659
|
+
kind: string;
|
|
660
|
+
thread: string;
|
|
661
|
+
created_at: string;
|
|
662
|
+
};
|
|
663
|
+
/** the sender's account handle; another account's sender never chooses the credentials a wake uses */
|
|
664
|
+
from_account: string;
|
|
665
|
+
same_account: boolean;
|
|
666
|
+
}
|
|
643
667
|
interface HubDeps {
|
|
644
668
|
sql: Sql;
|
|
645
669
|
/** synchronous sha256 hex */
|
|
@@ -659,6 +683,22 @@ interface HubDeps {
|
|
|
659
683
|
limits?: Partial<HubLimits>;
|
|
660
684
|
/** the link to other accounts' relays; without it an address of another account is not found */
|
|
661
685
|
crossAccount?: CrossAccountLink;
|
|
686
|
+
/** an HTTP client for machine waker URLs; without it (and without `machineWake`) no machine waker is called */
|
|
687
|
+
fetch?: (url: string, init: {
|
|
688
|
+
method: string;
|
|
689
|
+
headers: Record<string, string>;
|
|
690
|
+
body: string;
|
|
691
|
+
signal?: AbortSignal;
|
|
692
|
+
}) => Promise<{
|
|
693
|
+
status: number;
|
|
694
|
+
text?: () => Promise<string>;
|
|
695
|
+
}>;
|
|
696
|
+
/** the embedder's own machine waker, called in-process for any machine in place of a registered URL; metadata only, never a body or secret */
|
|
697
|
+
machineWake?: (event: MachineWakeEvent) => Promise<{
|
|
698
|
+
ok: boolean;
|
|
699
|
+
status?: number;
|
|
700
|
+
declined?: WakeDecline;
|
|
701
|
+
}>;
|
|
662
702
|
}
|
|
663
703
|
/** what a configured judge needs besides its policy */
|
|
664
704
|
interface HubJudgeOptions {
|
|
@@ -717,6 +757,8 @@ declare class RelayHub {
|
|
|
717
757
|
private readonly limits;
|
|
718
758
|
private judgeConfig;
|
|
719
759
|
private gate;
|
|
760
|
+
/** per machine, when its waker may be called next and the calls that failed in a row */
|
|
761
|
+
private readonly machineWakes;
|
|
720
762
|
constructor(deps: HubDeps);
|
|
721
763
|
/** idempotent: migrates and records the account handle (the address user part, e.g. "ada"); call before handling each request */
|
|
722
764
|
init(account: {
|
|
@@ -839,8 +881,18 @@ declare class RelayHub {
|
|
|
839
881
|
/** bytes of message bodies held, and of the messages from other accounts */
|
|
840
882
|
private storedNow;
|
|
841
883
|
private stash;
|
|
842
|
-
/**
|
|
884
|
+
/**
|
|
885
|
+
* wakes direct waiters and pushes a delivery for a mirrored row to the subscribed bridge of its machine; with none subscribed, a hibernated
|
|
886
|
+
* row's machine waker is called
|
|
887
|
+
*/
|
|
843
888
|
private route;
|
|
889
|
+
/** sets or (with a null url) clears the bridge's own machine waker: an https URL (http only to this host) and the secret that signs its calls */
|
|
890
|
+
private setMachineWake;
|
|
891
|
+
/**
|
|
892
|
+
* calls `machine`'s waker for its hibernated `recipient`: the embedder's `machineWake`, else the registered URL, signed. At most once per
|
|
893
|
+
* debounce window (doubling after failures); a decline counts toward nothing and holds calls for that message and its sender. Metadata only
|
|
894
|
+
*/
|
|
895
|
+
private machineWake;
|
|
844
896
|
/** a machine's endpoint changes: `endpoints` to the other bridges, presence and status to watchers */
|
|
845
897
|
private announce;
|
|
846
898
|
/** a direct client's endpoint changed: the other bridges mirror it */
|
|
@@ -900,4 +952,4 @@ declare class RelayHub {
|
|
|
900
952
|
xnotice(endpoint: string, text: string): void;
|
|
901
953
|
}
|
|
902
954
|
|
|
903
|
-
export { type DashboardEvent, type Deliver, type Delivery, FederatedCore, type FederatedOptions, GRAPH_MINUTES, GRAPH_MINUTES_MAX, type GraphEdge, type GraphMachine, type GraphNode, HUB_LIMITS, type HoldView, type HubAuth, type HubConn, type HubDeps, type HubGrant, type HubGraph, type HubJudgeOptions, type HubLimits, type HubRole, type HubSocket, JUDGE_LIMITS, type JudgeClientFactory, JudgeGate, type JudgeGateOptions, type JudgeLimits, type Origin as JudgeOrigin, LOCAL_EVENTS, LOCAL_EVENT_KINDS, MACHINE_NAME, type MachineRow, type MirrorCaps, type MirrorChange, type MirrorRules, type MirrorScope, type MirrorView, type RelayAuth, RelayHub, type RemoteView, type XEndpoint, dashboardEvent };
|
|
955
|
+
export { type DashboardEvent, type Deliver, type Delivery, FederatedCore, type FederatedOptions, GRAPH_MINUTES, GRAPH_MINUTES_MAX, type GraphEdge, type GraphMachine, type GraphNode, HUB_LIMITS, type HoldView, type HubAuth, type HubConn, type HubDeps, type HubGrant, type HubGraph, type HubJudgeOptions, type HubLimits, type HubRole, type HubSocket, JUDGE_LIMITS, type JudgeClientFactory, JudgeGate, type JudgeGateOptions, type JudgeLimits, type Origin as JudgeOrigin, LOCAL_EVENTS, LOCAL_EVENT_KINDS, MACHINE_NAME, type MachineRow, type MachineWakeEvent, type MirrorCaps, type MirrorChange, type MirrorRules, type MirrorScope, type MirrorView, type RelayAuth, RelayHub, type RemoteView, type XEndpoint, dashboardEvent };
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
MESSAGE_KINDS,
|
|
14
14
|
newId,
|
|
15
15
|
one,
|
|
16
|
+
PRESENCES,
|
|
16
17
|
randomHex,
|
|
17
18
|
RelayCore,
|
|
18
19
|
RelayError,
|
|
@@ -31,7 +32,7 @@ var MACHINE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
|
31
32
|
var REMOTE = /^[a-z0-9][a-z0-9._-]{0,63}\/([a-z0-9][a-z0-9._-]{0,62})?$/;
|
|
32
33
|
var MESSAGE_ID = /^[a-z]{2,8}_[a-z0-9]{8,64}$/;
|
|
33
34
|
var AUTHS = ["local-uid", "account", "account+bridge", "contact"];
|
|
34
|
-
var
|
|
35
|
+
var HOOK_WAKE = /^hook:[a-z0-9][a-z0-9-]{0,31}$/;
|
|
35
36
|
var UNKNOWN = { tree: null, parent_ep: null, depth: null };
|
|
36
37
|
var REFS = /* @__PURE__ */ new Set(["human", "policy", "inherited", "gone"]);
|
|
37
38
|
var INCARNATION = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
@@ -274,12 +275,20 @@ var FederatedCore = class extends RelayCore {
|
|
|
274
275
|
if (parent) return shown && !shown.has(parent.id) ? null : this.address(parent);
|
|
275
276
|
return shown ? null : this.remoteAddress(key);
|
|
276
277
|
}
|
|
277
|
-
/**
|
|
278
|
+
/**
|
|
279
|
+
* a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here; a hibernated one stays hibernated with
|
|
280
|
+
* its hook wake after its bridge closed
|
|
281
|
+
*/
|
|
278
282
|
view(row, shown) {
|
|
279
283
|
const view = super.view(row, shown);
|
|
280
284
|
const { remote, mirrored_presence: mirrored } = row;
|
|
285
|
+
if (remote && mirrored === "hibernated")
|
|
286
|
+
return { ...view, presence: "hibernated", ...view.kind === "agent" && row.wake && HOOK_WAKE.test(row.wake) ? { wake: row.wake } : {} };
|
|
281
287
|
return remote && row.connections > 0 && (mirrored === "online" || mirrored === "idle") ? { ...view, presence: mirrored } : view;
|
|
282
288
|
}
|
|
289
|
+
hibernated(row) {
|
|
290
|
+
return super.hibernated(row) || row.remote !== null && row.mirrored_presence === "hibernated";
|
|
291
|
+
}
|
|
283
292
|
address(row) {
|
|
284
293
|
const remote = row.remote;
|
|
285
294
|
if (remote && isForeign(remote)) return remote.slice(1);
|
|
@@ -439,7 +448,7 @@ var FederatedCore = class extends RelayCore {
|
|
|
439
448
|
for (const { view, id, before } of batch) {
|
|
440
449
|
const row = before ?? this.byId(id);
|
|
441
450
|
if (before && view.kind === "agent") this.dropEarlierHolder(before, view.scope.incarnation);
|
|
442
|
-
const live = view.presence !== "offline";
|
|
451
|
+
const live = view.presence !== "offline" && view.presence !== "hibernated";
|
|
443
452
|
const next = {
|
|
444
453
|
kind: view.kind,
|
|
445
454
|
harness: view.harness,
|
|
@@ -1322,7 +1331,7 @@ var FederatedCore = class extends RelayCore {
|
|
|
1322
1331
|
status = { state: view.status.state, note: cleanText(view.status.note, "note", LIMITS.noteChars) ?? "" };
|
|
1323
1332
|
}
|
|
1324
1333
|
const presence = view.presence ?? "offline";
|
|
1325
|
-
if (!PRESENCES.includes(presence)) throw new RelayError("invalid_params", "presence is online, idle or
|
|
1334
|
+
if (!PRESENCES.includes(presence)) throw new RelayError("invalid_params", "presence is online, idle, offline or hibernated");
|
|
1326
1335
|
const parsedSeen = typeof view.last_seen === "string" ? Date.parse(view.last_seen) : Number.NaN;
|
|
1327
1336
|
const out = {
|
|
1328
1337
|
handle,
|
|
@@ -1582,7 +1591,8 @@ function parseScope(view) {
|
|
|
1582
1591
|
throw new RelayError("too_large", `an endpoint's rules and caps are more than ${SCOPE_BYTES} bytes`);
|
|
1583
1592
|
if (given("title"))
|
|
1584
1593
|
out.title = view.title === null ? null : cleanText(typeof view.title === "string" ? view.title.replace(/\s+/g, " ") : view.title, "title", 64) || null;
|
|
1585
|
-
if (given("wake"))
|
|
1594
|
+
if (given("wake"))
|
|
1595
|
+
out.wake = typeof view.wake === "string" && view.wake !== "none" && (WAKES.includes(view.wake) || HOOK_WAKE.test(view.wake)) ? view.wake : null;
|
|
1586
1596
|
if (given("incarnation")) {
|
|
1587
1597
|
if (view.incarnation !== null && (typeof view.incarnation !== "string" || !INCARNATION.test(view.incarnation))) throw bad("incarnation");
|
|
1588
1598
|
out.incarnation = view.incarnation ?? null;
|
|
@@ -1618,6 +1628,7 @@ import {
|
|
|
1618
1628
|
SYSTEM_HANDLE as SYSTEM_HANDLE2,
|
|
1619
1629
|
sameSecret,
|
|
1620
1630
|
untilTomorrow,
|
|
1631
|
+
wakeDeclineOf,
|
|
1621
1632
|
XCounters,
|
|
1622
1633
|
XLIMITS,
|
|
1623
1634
|
xlimits
|
|
@@ -2210,6 +2221,8 @@ var RelayHub = class _RelayHub {
|
|
|
2210
2221
|
limits;
|
|
2211
2222
|
judgeConfig = null;
|
|
2212
2223
|
gate = null;
|
|
2224
|
+
/** per machine, when its waker may be called next and the calls that failed in a row */
|
|
2225
|
+
machineWakes = /* @__PURE__ */ new Map();
|
|
2213
2226
|
/** idempotent: migrates and records the account handle (the address user part, e.g. "ada"); call before handling each request */
|
|
2214
2227
|
init(account) {
|
|
2215
2228
|
if (this.core && this.account === account.user) return;
|
|
@@ -2217,6 +2230,8 @@ var RelayHub = class _RelayHub {
|
|
|
2217
2230
|
const columns = this.deps.sql.all(`PRAGMA table_info(machines)`).map((c) => c.name);
|
|
2218
2231
|
if (!columns.includes("user_id")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN user_id TEXT`);
|
|
2219
2232
|
if (!columns.includes("actor_id")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN actor_id TEXT`);
|
|
2233
|
+
if (!columns.includes("wake_url")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN wake_url TEXT`);
|
|
2234
|
+
if (!columns.includes("wake_secret")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN wake_secret TEXT`);
|
|
2220
2235
|
const core = new FederatedCore(this.deps.sql, {
|
|
2221
2236
|
user: account.user,
|
|
2222
2237
|
now: this.now,
|
|
@@ -2883,6 +2898,8 @@ var RelayHub = class _RelayHub {
|
|
|
2883
2898
|
conn.watching = true;
|
|
2884
2899
|
this.save(socket);
|
|
2885
2900
|
return core.snapshot({ activity: 20 });
|
|
2901
|
+
case "machine_wake":
|
|
2902
|
+
return this.setMachineWake(machine, params.url, params.secret);
|
|
2886
2903
|
}
|
|
2887
2904
|
throw new RelayError3("method_not_found", `no method ${method} on a bridge`);
|
|
2888
2905
|
}
|
|
@@ -3287,20 +3304,117 @@ var RelayHub = class _RelayHub {
|
|
|
3287
3304
|
stash(bytes) {
|
|
3288
3305
|
if (this.stored !== null) this.stored += bytes;
|
|
3289
3306
|
}
|
|
3290
|
-
/**
|
|
3307
|
+
/**
|
|
3308
|
+
* wakes direct waiters and pushes a delivery for a mirrored row to the subscribed bridge of its machine; with none subscribed, a hibernated
|
|
3309
|
+
* row's machine waker is called
|
|
3310
|
+
*/
|
|
3291
3311
|
route(seq, endpoint) {
|
|
3292
3312
|
this.wake(endpoint);
|
|
3293
3313
|
const delivery = this.relay.delivery(seq);
|
|
3294
3314
|
if (!delivery?.endpoint || !delivery.mirrored) return;
|
|
3295
3315
|
const machine = delivery.endpoint.slice(0, delivery.endpoint.indexOf("/"));
|
|
3316
|
+
let pushed = false;
|
|
3296
3317
|
for (const socket of this.deps.sockets()) {
|
|
3297
3318
|
if (socket.conn.role === "bridge" && socket.conn.subscribed && socket.conn.machine === machine) {
|
|
3298
3319
|
this.write(socket, { jsonrpc: "2.0", method: "deliver", params: { seq, endpoint: delivery.endpoint, message: delivery.message } });
|
|
3320
|
+
pushed = true;
|
|
3299
3321
|
}
|
|
3300
3322
|
}
|
|
3323
|
+
if (!pushed && delivery.recipient) this.machineWake(machine, delivery.recipient, delivery.message);
|
|
3324
|
+
}
|
|
3325
|
+
/** sets or (with a null url) clears the bridge's own machine waker: an https URL (http only to this host) and the secret that signs its calls */
|
|
3326
|
+
setMachineWake(machine, url, secret) {
|
|
3327
|
+
if (url === null || url === void 0) {
|
|
3328
|
+
this.deps.sql.all(`UPDATE machines SET wake_url = NULL, wake_secret = NULL WHERE name = ?`, machine);
|
|
3329
|
+
this.machineWakes.delete(machine);
|
|
3330
|
+
this.relay.record("machine_wake_set", null, machine, { machine, set: false });
|
|
3331
|
+
return { set: false };
|
|
3332
|
+
}
|
|
3333
|
+
if (typeof url !== "string" || url.length > MACHINE_WAKE_URL_CHARS || !wakeUrl(url))
|
|
3334
|
+
throw new RelayError3("invalid_params", `url is an https URL of at most ${MACHINE_WAKE_URL_CHARS} characters (http only to localhost)`);
|
|
3335
|
+
if (typeof secret !== "string" || secret.length < 16 || secret.length > 256)
|
|
3336
|
+
throw new RelayError3("invalid_params", "secret is 16 to 256 characters, and a url needs one");
|
|
3337
|
+
this.deps.sql.all(`UPDATE machines SET wake_url = ?, wake_secret = ? WHERE name = ?`, url, secret, machine);
|
|
3338
|
+
this.machineWakes.delete(machine);
|
|
3339
|
+
this.relay.record("machine_wake_set", null, machine, { machine, set: true });
|
|
3340
|
+
return { set: true };
|
|
3341
|
+
}
|
|
3342
|
+
/**
|
|
3343
|
+
* calls `machine`'s waker for its hibernated `recipient`: the embedder's `machineWake`, else the registered URL, signed. At most once per
|
|
3344
|
+
* debounce window (doubling after failures); a decline counts toward nothing and holds calls for that message and its sender. Metadata only
|
|
3345
|
+
*/
|
|
3346
|
+
machineWake(machine, recipient, message) {
|
|
3347
|
+
const embedded = this.deps.machineWake;
|
|
3348
|
+
if (!embedded && !this.deps.fetch) return;
|
|
3349
|
+
const now = this.now();
|
|
3350
|
+
const state = this.machineWakes.get(machine);
|
|
3351
|
+
if (state && state.next > now) return;
|
|
3352
|
+
if (state?.declined && (state.declined.message === message.id || state.declined.from === message.from.address)) return;
|
|
3353
|
+
let view;
|
|
3354
|
+
try {
|
|
3355
|
+
view = this.relay.whoami(recipient);
|
|
3356
|
+
} catch {
|
|
3357
|
+
return;
|
|
3358
|
+
}
|
|
3359
|
+
if (view.presence !== "hibernated") return;
|
|
3360
|
+
const waker = embedded ? null : one3(this.deps.sql, `SELECT wake_url, wake_secret FROM machines WHERE name = ?`, machine);
|
|
3361
|
+
if (!embedded && (!waker?.wake_url || !waker.wake_secret)) return;
|
|
3362
|
+
const fails = state?.fails ?? 0;
|
|
3363
|
+
this.machineWakes.set(machine, { next: now + LIMITS3.wakeDebounceS * 1e3, fails });
|
|
3364
|
+
const fromAccount = message.from.account ?? this.account;
|
|
3365
|
+
const event = {
|
|
3366
|
+
scp: 1,
|
|
3367
|
+
event: "machine_wake",
|
|
3368
|
+
account: this.account,
|
|
3369
|
+
machine,
|
|
3370
|
+
endpoints: [view.address],
|
|
3371
|
+
message: { id: message.id, from: message.from.address, kind: message.kind, thread: message.thread, created_at: message.created_at },
|
|
3372
|
+
from_account: fromAccount,
|
|
3373
|
+
same_account: fromAccount === this.account
|
|
3374
|
+
};
|
|
3375
|
+
const done = (ok, detail) => {
|
|
3376
|
+
if (detail.declined)
|
|
3377
|
+
this.machineWakes.set(machine, { next: now, fails, declined: { reason: detail.declined, message: message.id, from: message.from.address } });
|
|
3378
|
+
else {
|
|
3379
|
+
const next = ok ? 0 : fails + 1;
|
|
3380
|
+
const backoff = ok ? LIMITS3.wakeDebounceS : Math.min(LIMITS3.wakeDebounceS * 2 ** next, LIMITS3.wakeBackoffMaxS);
|
|
3381
|
+
this.machineWakes.set(machine, { next: now + backoff * 1e3, fails: next });
|
|
3382
|
+
}
|
|
3383
|
+
try {
|
|
3384
|
+
this.relay.record("machine_wake", null, machine, { machine, ok, ...detail });
|
|
3385
|
+
} catch {
|
|
3386
|
+
}
|
|
3387
|
+
this.deps.log?.("relay_action", {
|
|
3388
|
+
account: this.account,
|
|
3389
|
+
machine,
|
|
3390
|
+
method: "machine_wake",
|
|
3391
|
+
code: ok ? "ok" : detail.declined ? "declined" : "failed",
|
|
3392
|
+
...detail
|
|
3393
|
+
});
|
|
3394
|
+
};
|
|
3395
|
+
void (async () => {
|
|
3396
|
+
if (embedded) {
|
|
3397
|
+
const result = await embedded(event);
|
|
3398
|
+
const declined2 = result.ok ? void 0 : wakeDeclineOf(result.declined) ?? void 0;
|
|
3399
|
+
return done(result.ok, { ...result.status !== void 0 ? { status: result.status } : {}, ...declined2 ? { declined: declined2 } : {} });
|
|
3400
|
+
}
|
|
3401
|
+
const body = JSON.stringify(event);
|
|
3402
|
+
const timestamp = String(now);
|
|
3403
|
+
const signature = await hmacHex(waker.wake_secret, `${timestamp}.${body}`);
|
|
3404
|
+
const response = await this.deps.fetch(waker.wake_url, {
|
|
3405
|
+
method: "POST",
|
|
3406
|
+
headers: { "content-type": "application/json", "x-scp-timestamp": timestamp, "x-scp-signature": `sha256=${signature}` },
|
|
3407
|
+
body,
|
|
3408
|
+
signal: AbortSignal.timeout(MACHINE_WAKE_TIMEOUT_MS)
|
|
3409
|
+
});
|
|
3410
|
+
const declined = response.status === 403 ? await declineOf(response) : null;
|
|
3411
|
+
done(response.status >= 200 && response.status < 300, { status: response.status, ...declined ? { declined } : {} });
|
|
3412
|
+
})().catch((error) => done(false, { error: error instanceof Error ? error.name : "error" }));
|
|
3301
3413
|
}
|
|
3302
3414
|
/** a machine's endpoint changes: `endpoints` to the other bridges, presence and status to watchers */
|
|
3303
3415
|
announce(machine, changes, full) {
|
|
3416
|
+
const waking = this.machineWakes.get(machine);
|
|
3417
|
+
if (waking?.declined && changes.some((c) => c.presence && c.view.presence === "hibernated")) delete waking.declined;
|
|
3304
3418
|
if (full || changes.length) {
|
|
3305
3419
|
const endpoints = full ? this.relay.remoteRowsOf(machine) : changes.map((c) => c.was === void 0 ? c.view : { ...c.view, was: c.was });
|
|
3306
3420
|
this.toBridges(machine, { machine, endpoints, full });
|
|
@@ -3501,13 +3615,16 @@ var RelayHub = class _RelayHub {
|
|
|
3501
3615
|
}
|
|
3502
3616
|
machineList() {
|
|
3503
3617
|
const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id));
|
|
3504
|
-
const bound = this.deps.sql.all(
|
|
3618
|
+
const bound = this.deps.sql.all(
|
|
3619
|
+
`SELECT name, kind, created_at, last_seen, wake_url FROM machines ORDER BY name`
|
|
3620
|
+
).map((row) => ({
|
|
3505
3621
|
name: row.name,
|
|
3506
3622
|
kind: row.kind,
|
|
3507
3623
|
online: sockets.some((s) => this.boundMachine(s.conn) === row.name),
|
|
3508
3624
|
bridge: sockets.some((s) => s.conn.role === "bridge" && s.conn.machine === row.name),
|
|
3509
3625
|
created_at: iso2(row.created_at),
|
|
3510
|
-
last_seen: iso2(row.last_seen)
|
|
3626
|
+
last_seen: iso2(row.last_seen),
|
|
3627
|
+
wake: row.wake_url !== null
|
|
3511
3628
|
}));
|
|
3512
3629
|
const revoked = this.deps.sql.all(`SELECT name, at FROM machine_tombstones WHERE name NOT IN (SELECT name FROM machines) ORDER BY name`).map((row) => ({ name: row.name, kind: "revoked", online: false, bridge: false, created_at: null, last_seen: iso2(row.at) }));
|
|
3513
3630
|
return [...bound, ...revoked];
|
|
@@ -3648,6 +3765,32 @@ var RelayHub = class _RelayHub {
|
|
|
3648
3765
|
this.route(this.relay.judgeNoticeTo(endpoint, text), endpoint);
|
|
3649
3766
|
}
|
|
3650
3767
|
};
|
|
3768
|
+
var MACHINE_WAKE_URL_CHARS = 2048;
|
|
3769
|
+
var MACHINE_WAKE_TIMEOUT_MS = 1e4;
|
|
3770
|
+
function wakeUrl(value) {
|
|
3771
|
+
let url;
|
|
3772
|
+
try {
|
|
3773
|
+
url = new URL(value);
|
|
3774
|
+
} catch {
|
|
3775
|
+
return false;
|
|
3776
|
+
}
|
|
3777
|
+
if (url.username || url.password) return false;
|
|
3778
|
+
if (url.protocol === "https:") return true;
|
|
3779
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]");
|
|
3780
|
+
}
|
|
3781
|
+
async function hmacHex(secret, text) {
|
|
3782
|
+
const encoder = new TextEncoder();
|
|
3783
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
3784
|
+
const signature = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(text)));
|
|
3785
|
+
return Array.from(signature, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
3786
|
+
}
|
|
3787
|
+
async function declineOf(response) {
|
|
3788
|
+
try {
|
|
3789
|
+
return wakeDeclineOf(JSON.parse(await response.text?.() ?? "").declined);
|
|
3790
|
+
} catch {
|
|
3791
|
+
return null;
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3651
3794
|
export {
|
|
3652
3795
|
FederatedCore,
|
|
3653
3796
|
GRAPH_MINUTES,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scp-dev/hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Swarm Communication Protocol federation and account hub: mirrored endpoints across machines, bridges, direct clients and cross-account delivery.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://scp.dev",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"typecheck": "tsc -p ."
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@scp-dev/core": "0.
|
|
32
|
+
"@scp-dev/core": "0.5.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"tsup": "^8.5.1"
|
package/src/federation.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
newId,
|
|
22
22
|
one,
|
|
23
23
|
type PolicySubject,
|
|
24
|
+
PRESENCES,
|
|
24
25
|
type Presence,
|
|
25
26
|
randomHex,
|
|
26
27
|
RelayCore,
|
|
@@ -57,7 +58,8 @@ export const MACHINE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
|
57
58
|
const REMOTE = /^[a-z0-9][a-z0-9._-]{0,63}\/([a-z0-9][a-z0-9._-]{0,62})?$/
|
|
58
59
|
const MESSAGE_ID = /^[a-z]{2,8}_[a-z0-9]{8,64}$/
|
|
59
60
|
const AUTHS: readonly RelayAuth[] = ["local-uid", "account", "account+bridge", "contact"]
|
|
60
|
-
|
|
61
|
+
/** a hibernated agent's shown wake, its provider's hook */
|
|
62
|
+
const HOOK_WAKE = /^hook:[a-z0-9][a-z0-9-]{0,31}$/
|
|
61
63
|
|
|
62
64
|
export interface FederatedOptions extends CoreOptions {
|
|
63
65
|
/** this host's own machine name; addresses naming it stay local */
|
|
@@ -510,13 +512,22 @@ export class FederatedCore extends RelayCore {
|
|
|
510
512
|
return shown ? null : this.remoteAddress(key)
|
|
511
513
|
}
|
|
512
514
|
|
|
513
|
-
/**
|
|
515
|
+
/**
|
|
516
|
+
* a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here; a hibernated one stays hibernated with
|
|
517
|
+
* its hook wake after its bridge closed
|
|
518
|
+
*/
|
|
514
519
|
protected override view(row: EndpointRow, shown?: Set<string>): EndpointView {
|
|
515
520
|
const view = super.view(row, shown)
|
|
516
521
|
const { remote, mirrored_presence: mirrored } = row as FederatedRow
|
|
522
|
+
if (remote && mirrored === "hibernated")
|
|
523
|
+
return { ...view, presence: "hibernated", ...(view.kind === "agent" && row.wake && HOOK_WAKE.test(row.wake) ? { wake: row.wake as Wake } : {}) }
|
|
517
524
|
return remote && row.connections > 0 && (mirrored === "online" || mirrored === "idle") ? { ...view, presence: mirrored } : view
|
|
518
525
|
}
|
|
519
526
|
|
|
527
|
+
protected override hibernated(row: EndpointRow): boolean {
|
|
528
|
+
return super.hibernated(row) || ((row as FederatedRow).remote !== null && (row as FederatedRow).mirrored_presence === "hibernated")
|
|
529
|
+
}
|
|
530
|
+
|
|
520
531
|
protected override address(row: EndpointRow): string {
|
|
521
532
|
const remote = (row as FederatedRow).remote
|
|
522
533
|
if (remote && isForeign(remote)) return remote.slice(1)
|
|
@@ -686,7 +697,7 @@ export class FederatedCore extends RelayCore {
|
|
|
686
697
|
for (const { view, id, before } of batch) {
|
|
687
698
|
const row = before ?? (this.byId(id) as FederatedRow)
|
|
688
699
|
if (before && view.kind === "agent") this.dropEarlierHolder(before, view.scope.incarnation)
|
|
689
|
-
const live = view.presence !== "offline"
|
|
700
|
+
const live = view.presence !== "offline" && view.presence !== "hibernated"
|
|
690
701
|
const next: Partial<FederatedRow> = {
|
|
691
702
|
kind: view.kind,
|
|
692
703
|
harness: view.harness,
|
|
@@ -1664,7 +1675,7 @@ export class FederatedCore extends RelayCore {
|
|
|
1664
1675
|
status = { state: view.status.state, note: cleanText(view.status.note, "note", LIMITS.noteChars) ?? "" }
|
|
1665
1676
|
}
|
|
1666
1677
|
const presence = view.presence ?? "offline"
|
|
1667
|
-
if (!PRESENCES.includes(presence)) throw new RelayError("invalid_params", "presence is online, idle or
|
|
1678
|
+
if (!PRESENCES.includes(presence)) throw new RelayError("invalid_params", "presence is online, idle, offline or hibernated")
|
|
1668
1679
|
const parsedSeen = typeof view.last_seen === "string" ? Date.parse(view.last_seen) : Number.NaN
|
|
1669
1680
|
const out = {
|
|
1670
1681
|
handle,
|
|
@@ -1958,7 +1969,9 @@ function parseScope(view: Partial<MirrorView>): ParsedScope {
|
|
|
1958
1969
|
throw new RelayError("too_large", `an endpoint's rules and caps are more than ${SCOPE_BYTES} bytes`)
|
|
1959
1970
|
if (given("title"))
|
|
1960
1971
|
out.title = view.title === null ? null : cleanText(typeof view.title === "string" ? view.title.replace(/\s+/g, " ") : view.title, "title", 64) || null
|
|
1961
|
-
if (given("wake"))
|
|
1972
|
+
if (given("wake"))
|
|
1973
|
+
out.wake =
|
|
1974
|
+
typeof view.wake === "string" && view.wake !== "none" && (WAKES.includes(view.wake as Wake) || HOOK_WAKE.test(view.wake)) ? (view.wake as Wake) : null
|
|
1962
1975
|
if (given("incarnation")) {
|
|
1963
1976
|
if (view.incarnation !== null && (typeof view.incarnation !== "string" || !INCARNATION.test(view.incarnation))) throw bad("incarnation")
|
|
1964
1977
|
out.incarnation = view.incarnation ?? null
|
package/src/hub.ts
CHANGED
|
@@ -39,6 +39,8 @@ import {
|
|
|
39
39
|
sameSecret,
|
|
40
40
|
untilTomorrow,
|
|
41
41
|
type Wake,
|
|
42
|
+
type WakeDecline,
|
|
43
|
+
wakeDeclineOf,
|
|
42
44
|
type XContact,
|
|
43
45
|
XCounters,
|
|
44
46
|
type XDelivered,
|
|
@@ -93,6 +95,8 @@ export interface MachineRow {
|
|
|
93
95
|
bridge: boolean
|
|
94
96
|
created_at: string | null
|
|
95
97
|
last_seen: string | null
|
|
98
|
+
/** a machine waker URL is set; the URL and its secret are never shown */
|
|
99
|
+
wake?: boolean
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
/** an endpoint as the dashboard graph draws it */
|
|
@@ -158,6 +162,19 @@ export interface HubSocket {
|
|
|
158
162
|
close(code: number, reason: string): void
|
|
159
163
|
}
|
|
160
164
|
|
|
165
|
+
/** what a machine waker gets: who to wake and the message's metadata, never its body */
|
|
166
|
+
export interface MachineWakeEvent {
|
|
167
|
+
scp: 1
|
|
168
|
+
event: "machine_wake"
|
|
169
|
+
account: string
|
|
170
|
+
machine: string
|
|
171
|
+
endpoints: string[]
|
|
172
|
+
message: { id: string; from: string; kind: string; thread: string; created_at: string }
|
|
173
|
+
/** the sender's account handle; another account's sender never chooses the credentials a wake uses */
|
|
174
|
+
from_account: string
|
|
175
|
+
same_account: boolean
|
|
176
|
+
}
|
|
177
|
+
|
|
161
178
|
export interface HubDeps {
|
|
162
179
|
sql: Sql
|
|
163
180
|
/** synchronous sha256 hex */
|
|
@@ -177,6 +194,13 @@ export interface HubDeps {
|
|
|
177
194
|
limits?: Partial<HubLimits>
|
|
178
195
|
/** the link to other accounts' relays; without it an address of another account is not found */
|
|
179
196
|
crossAccount?: CrossAccountLink
|
|
197
|
+
/** an HTTP client for machine waker URLs; without it (and without `machineWake`) no machine waker is called */
|
|
198
|
+
fetch?: (
|
|
199
|
+
url: string,
|
|
200
|
+
init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal },
|
|
201
|
+
) => Promise<{ status: number; text?: () => Promise<string> }>
|
|
202
|
+
/** the embedder's own machine waker, called in-process for any machine in place of a registered URL; metadata only, never a body or secret */
|
|
203
|
+
machineWake?: (event: MachineWakeEvent) => Promise<{ ok: boolean; status?: number; declined?: WakeDecline }>
|
|
180
204
|
}
|
|
181
205
|
|
|
182
206
|
/** what a configured judge needs besides its policy */
|
|
@@ -331,6 +355,8 @@ export class RelayHub {
|
|
|
331
355
|
private readonly limits: Readonly<HubLimits>
|
|
332
356
|
private judgeConfig: { policy: JudgePolicy; options: HubJudgeOptions } | null = null
|
|
333
357
|
private gate: JudgeGate | null = null
|
|
358
|
+
/** per machine, when its waker may be called next and the calls that failed in a row */
|
|
359
|
+
private readonly machineWakes = new Map<string, { next: number; fails: number; declined?: { reason: WakeDecline; message: string; from: string } }>()
|
|
334
360
|
|
|
335
361
|
constructor(private readonly deps: HubDeps) {
|
|
336
362
|
this.now = deps.now ?? Date.now
|
|
@@ -345,6 +371,8 @@ export class RelayHub {
|
|
|
345
371
|
const columns = this.deps.sql.all<{ name: string }>(`PRAGMA table_info(machines)`).map((c) => c.name)
|
|
346
372
|
if (!columns.includes("user_id")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN user_id TEXT`)
|
|
347
373
|
if (!columns.includes("actor_id")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN actor_id TEXT`)
|
|
374
|
+
if (!columns.includes("wake_url")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN wake_url TEXT`)
|
|
375
|
+
if (!columns.includes("wake_secret")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN wake_secret TEXT`)
|
|
348
376
|
const core = new FederatedCore(this.deps.sql, {
|
|
349
377
|
user: account.user,
|
|
350
378
|
now: this.now,
|
|
@@ -1049,6 +1077,8 @@ export class RelayHub {
|
|
|
1049
1077
|
conn.watching = true
|
|
1050
1078
|
this.save(socket)
|
|
1051
1079
|
return core.snapshot({ activity: 20 })
|
|
1080
|
+
case "machine_wake":
|
|
1081
|
+
return this.setMachineWake(machine, params.url, params.secret)
|
|
1052
1082
|
}
|
|
1053
1083
|
throw new RelayError("method_not_found", `no method ${method} on a bridge`)
|
|
1054
1084
|
}
|
|
@@ -1491,21 +1521,121 @@ export class RelayHub {
|
|
|
1491
1521
|
if (this.stored !== null) this.stored += bytes
|
|
1492
1522
|
}
|
|
1493
1523
|
|
|
1494
|
-
/**
|
|
1524
|
+
/**
|
|
1525
|
+
* wakes direct waiters and pushes a delivery for a mirrored row to the subscribed bridge of its machine; with none subscribed, a hibernated
|
|
1526
|
+
* row's machine waker is called
|
|
1527
|
+
*/
|
|
1495
1528
|
private route(seq: number, endpoint: string): void {
|
|
1496
1529
|
this.wake(endpoint)
|
|
1497
1530
|
const delivery = this.relay.delivery(seq)
|
|
1498
1531
|
if (!delivery?.endpoint || !delivery.mirrored) return
|
|
1499
1532
|
const machine = delivery.endpoint.slice(0, delivery.endpoint.indexOf("/"))
|
|
1533
|
+
let pushed = false
|
|
1500
1534
|
for (const socket of this.deps.sockets()) {
|
|
1501
1535
|
if (socket.conn.role === "bridge" && socket.conn.subscribed && socket.conn.machine === machine) {
|
|
1502
1536
|
this.write(socket, { jsonrpc: "2.0", method: "deliver", params: { seq, endpoint: delivery.endpoint, message: delivery.message } })
|
|
1537
|
+
pushed = true
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
if (!pushed && delivery.recipient) this.machineWake(machine, delivery.recipient, delivery.message)
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** sets or (with a null url) clears the bridge's own machine waker: an https URL (http only to this host) and the secret that signs its calls */
|
|
1544
|
+
private setMachineWake(machine: string, url: unknown, secret: unknown): { set: boolean } {
|
|
1545
|
+
if (url === null || url === undefined) {
|
|
1546
|
+
this.deps.sql.all(`UPDATE machines SET wake_url = NULL, wake_secret = NULL WHERE name = ?`, machine)
|
|
1547
|
+
this.machineWakes.delete(machine)
|
|
1548
|
+
this.relay.record("machine_wake_set", null, machine, { machine, set: false })
|
|
1549
|
+
return { set: false }
|
|
1550
|
+
}
|
|
1551
|
+
if (typeof url !== "string" || url.length > MACHINE_WAKE_URL_CHARS || !wakeUrl(url))
|
|
1552
|
+
throw new RelayError("invalid_params", `url is an https URL of at most ${MACHINE_WAKE_URL_CHARS} characters (http only to localhost)`)
|
|
1553
|
+
if (typeof secret !== "string" || secret.length < 16 || secret.length > 256)
|
|
1554
|
+
throw new RelayError("invalid_params", "secret is 16 to 256 characters, and a url needs one")
|
|
1555
|
+
this.deps.sql.all(`UPDATE machines SET wake_url = ?, wake_secret = ? WHERE name = ?`, url, secret, machine)
|
|
1556
|
+
this.machineWakes.delete(machine)
|
|
1557
|
+
this.relay.record("machine_wake_set", null, machine, { machine, set: true })
|
|
1558
|
+
return { set: true }
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* calls `machine`'s waker for its hibernated `recipient`: the embedder's `machineWake`, else the registered URL, signed. At most once per
|
|
1563
|
+
* debounce window (doubling after failures); a decline counts toward nothing and holds calls for that message and its sender. Metadata only
|
|
1564
|
+
*/
|
|
1565
|
+
private machineWake(machine: string, recipient: string, message: Envelope): void {
|
|
1566
|
+
const embedded = this.deps.machineWake
|
|
1567
|
+
if (!embedded && !this.deps.fetch) return
|
|
1568
|
+
const now = this.now()
|
|
1569
|
+
const state = this.machineWakes.get(machine)
|
|
1570
|
+
if (state && state.next > now) return
|
|
1571
|
+
if (state?.declined && (state.declined.message === message.id || state.declined.from === message.from.address)) return
|
|
1572
|
+
let view: EndpointView
|
|
1573
|
+
try {
|
|
1574
|
+
view = this.relay.whoami(recipient)
|
|
1575
|
+
} catch {
|
|
1576
|
+
return
|
|
1577
|
+
}
|
|
1578
|
+
if (view.presence !== "hibernated") return
|
|
1579
|
+
const waker = embedded
|
|
1580
|
+
? null
|
|
1581
|
+
: one<{ wake_url: string | null; wake_secret: string | null }>(this.deps.sql, `SELECT wake_url, wake_secret FROM machines WHERE name = ?`, machine)
|
|
1582
|
+
if (!embedded && (!waker?.wake_url || !waker.wake_secret)) return
|
|
1583
|
+
const fails = state?.fails ?? 0
|
|
1584
|
+
this.machineWakes.set(machine, { next: now + LIMITS.wakeDebounceS * 1000, fails })
|
|
1585
|
+
const fromAccount = message.from.account ?? this.account
|
|
1586
|
+
const event: MachineWakeEvent = {
|
|
1587
|
+
scp: 1,
|
|
1588
|
+
event: "machine_wake",
|
|
1589
|
+
account: this.account,
|
|
1590
|
+
machine,
|
|
1591
|
+
endpoints: [view.address],
|
|
1592
|
+
message: { id: message.id, from: message.from.address, kind: message.kind, thread: message.thread, created_at: message.created_at },
|
|
1593
|
+
from_account: fromAccount,
|
|
1594
|
+
same_account: fromAccount === this.account,
|
|
1595
|
+
}
|
|
1596
|
+
const done = (ok: boolean, detail: { status?: number; error?: string; declined?: WakeDecline }) => {
|
|
1597
|
+
if (detail.declined)
|
|
1598
|
+
this.machineWakes.set(machine, { next: now, fails, declined: { reason: detail.declined, message: message.id, from: message.from.address } })
|
|
1599
|
+
else {
|
|
1600
|
+
const next = ok ? 0 : fails + 1
|
|
1601
|
+
const backoff = ok ? LIMITS.wakeDebounceS : Math.min(LIMITS.wakeDebounceS * 2 ** next, LIMITS.wakeBackoffMaxS)
|
|
1602
|
+
this.machineWakes.set(machine, { next: now + backoff * 1000, fails: next })
|
|
1503
1603
|
}
|
|
1604
|
+
try {
|
|
1605
|
+
this.relay.record("machine_wake", null, machine, { machine, ok, ...detail })
|
|
1606
|
+
} catch {}
|
|
1607
|
+
this.deps.log?.("relay_action", {
|
|
1608
|
+
account: this.account,
|
|
1609
|
+
machine,
|
|
1610
|
+
method: "machine_wake",
|
|
1611
|
+
code: ok ? "ok" : detail.declined ? "declined" : "failed",
|
|
1612
|
+
...detail,
|
|
1613
|
+
})
|
|
1504
1614
|
}
|
|
1615
|
+
void (async () => {
|
|
1616
|
+
if (embedded) {
|
|
1617
|
+
const result = await embedded(event)
|
|
1618
|
+
const declined = result.ok ? undefined : (wakeDeclineOf(result.declined) ?? undefined)
|
|
1619
|
+
return done(result.ok, { ...(result.status !== undefined ? { status: result.status } : {}), ...(declined ? { declined } : {}) })
|
|
1620
|
+
}
|
|
1621
|
+
const body = JSON.stringify(event)
|
|
1622
|
+
const timestamp = String(now)
|
|
1623
|
+
const signature = await hmacHex(waker!.wake_secret!, `${timestamp}.${body}`)
|
|
1624
|
+
const response = await this.deps.fetch!(waker!.wake_url!, {
|
|
1625
|
+
method: "POST",
|
|
1626
|
+
headers: { "content-type": "application/json", "x-scp-timestamp": timestamp, "x-scp-signature": `sha256=${signature}` },
|
|
1627
|
+
body,
|
|
1628
|
+
signal: AbortSignal.timeout(MACHINE_WAKE_TIMEOUT_MS),
|
|
1629
|
+
})
|
|
1630
|
+
const declined = response.status === 403 ? await declineOf(response) : null
|
|
1631
|
+
done(response.status >= 200 && response.status < 300, { status: response.status, ...(declined ? { declined } : {}) })
|
|
1632
|
+
})().catch((error: unknown) => done(false, { error: error instanceof Error ? error.name : "error" }))
|
|
1505
1633
|
}
|
|
1506
1634
|
|
|
1507
1635
|
/** a machine's endpoint changes: `endpoints` to the other bridges, presence and status to watchers */
|
|
1508
1636
|
private announce(machine: string, changes: MirrorChange[], full: boolean): void {
|
|
1637
|
+
const waking = this.machineWakes.get(machine)
|
|
1638
|
+
if (waking?.declined && changes.some((c) => c.presence && c.view.presence === "hibernated")) delete waking.declined
|
|
1509
1639
|
if (full || changes.length) {
|
|
1510
1640
|
const endpoints = full ? this.relay.remoteRowsOf(machine) : changes.map((c) => (c.was === undefined ? c.view : { ...c.view, was: c.was }))
|
|
1511
1641
|
this.toBridges(machine, { machine, endpoints, full })
|
|
@@ -1729,7 +1859,9 @@ export class RelayHub {
|
|
|
1729
1859
|
private machineList(): MachineRow[] {
|
|
1730
1860
|
const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id))
|
|
1731
1861
|
const bound = this.deps.sql
|
|
1732
|
-
.all<{ name: string; kind: string; created_at: number; last_seen: number | null
|
|
1862
|
+
.all<{ name: string; kind: string; created_at: number; last_seen: number | null; wake_url: string | null }>(
|
|
1863
|
+
`SELECT name, kind, created_at, last_seen, wake_url FROM machines ORDER BY name`,
|
|
1864
|
+
)
|
|
1733
1865
|
.map((row) => ({
|
|
1734
1866
|
name: row.name,
|
|
1735
1867
|
kind: row.kind,
|
|
@@ -1737,6 +1869,7 @@ export class RelayHub {
|
|
|
1737
1869
|
bridge: sockets.some((s) => s.conn.role === "bridge" && s.conn.machine === row.name),
|
|
1738
1870
|
created_at: iso(row.created_at),
|
|
1739
1871
|
last_seen: iso(row.last_seen),
|
|
1872
|
+
wake: row.wake_url !== null,
|
|
1740
1873
|
}))
|
|
1741
1874
|
const revoked = this.deps.sql
|
|
1742
1875
|
.all<{ name: string; at: number }>(`SELECT name, at FROM machine_tombstones WHERE name NOT IN (SELECT name FROM machines) ORDER BY name`)
|
|
@@ -1889,3 +2022,36 @@ export class RelayHub {
|
|
|
1889
2022
|
this.route(this.relay.judgeNoticeTo(endpoint, text), endpoint)
|
|
1890
2023
|
}
|
|
1891
2024
|
}
|
|
2025
|
+
|
|
2026
|
+
const MACHINE_WAKE_URL_CHARS = 2048
|
|
2027
|
+
const MACHINE_WAKE_TIMEOUT_MS = 10_000
|
|
2028
|
+
|
|
2029
|
+
/** an https URL, or http to this host, with no credentials in it */
|
|
2030
|
+
function wakeUrl(value: string): boolean {
|
|
2031
|
+
let url: URL
|
|
2032
|
+
try {
|
|
2033
|
+
url = new URL(value)
|
|
2034
|
+
} catch {
|
|
2035
|
+
return false
|
|
2036
|
+
}
|
|
2037
|
+
if (url.username || url.password) return false
|
|
2038
|
+
if (url.protocol === "https:") return true
|
|
2039
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]")
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
/** HMAC-SHA256 of `text` under `secret`, hex, with WebCrypto (Bun and Workers both have it) */
|
|
2043
|
+
async function hmacHex(secret: string, text: string): Promise<string> {
|
|
2044
|
+
const encoder = new TextEncoder()
|
|
2045
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"])
|
|
2046
|
+
const signature = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(text)))
|
|
2047
|
+
return Array.from(signature, (byte) => byte.toString(16).padStart(2, "0")).join("")
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/** a 403 answer's `{"declined": "<reason>"}`, or null */
|
|
2051
|
+
async function declineOf(response: { text?: () => Promise<string> }): Promise<WakeDecline | null> {
|
|
2052
|
+
try {
|
|
2053
|
+
return wakeDeclineOf((JSON.parse((await response.text?.()) ?? "") as { declined?: unknown }).declined)
|
|
2054
|
+
} catch {
|
|
2055
|
+
return null
|
|
2056
|
+
}
|
|
2057
|
+
}
|