@nordbyte/nordrelay 0.6.0 → 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/.env.example +17 -0
- package/README.md +67 -6
- package/dist/access-control.js +6 -1
- package/dist/activity-events.js +2 -2
- package/dist/bot-preferences.js +1 -0
- package/dist/bot.js +77 -6
- package/dist/channel-adapter.js +11 -5
- package/dist/channel-command-catalog.js +88 -0
- package/dist/channel-command-service.js +214 -1
- package/dist/channel-mirror-registry.js +77 -0
- package/dist/channel-peer-prompt.js +95 -0
- package/dist/channel-runtime.js +12 -5
- package/dist/codex-state.js +114 -78
- package/dist/config-metadata.js +15 -0
- package/dist/config.js +31 -6
- package/dist/context-key.js +10 -0
- package/dist/discord-bot.js +85 -26
- package/dist/discord-command-surface.js +11 -73
- package/dist/index.js +20 -0
- package/dist/metrics.js +46 -0
- package/dist/peer-auth.js +85 -0
- package/dist/peer-client.js +256 -0
- package/dist/peer-context.js +21 -0
- package/dist/peer-identity.js +127 -0
- package/dist/peer-runtime-service.js +636 -0
- package/dist/peer-server.js +220 -0
- package/dist/peer-store.js +294 -0
- package/dist/peer-types.js +52 -0
- package/dist/relay-runtime-helpers.js +208 -0
- package/dist/relay-runtime.js +72 -274
- package/dist/remote-prompt.js +98 -0
- package/dist/telegram-command-menu.js +3 -53
- package/dist/telegram-general-commands.js +14 -0
- package/dist/telegram-preference-commands.js +23 -127
- package/dist/web-api-contract.js +8 -0
- package/dist/web-dashboard-pages.js +12 -0
- package/dist/web-dashboard-peer-routes.js +204 -0
- package/dist/web-dashboard-ui.js +1 -0
- package/dist/web-dashboard.js +12 -0
- package/dist/webui-assets/dashboard.js +427 -14
- package/package.json +3 -2
- package/plugins/nordrelay/scripts/nordrelay.mjs +373 -7
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { permissionForCommand } from "./access-control.js";
|
|
2
|
+
import { discordCommandCatalog } from "./channel-command-catalog.js";
|
|
3
|
+
import { normalizeChannelCommandName, parseChannelCommand } from "./channel-runtime.js";
|
|
2
4
|
export function parseDiscordMessageCommand(text) {
|
|
3
|
-
|
|
4
|
-
return match?.[1] ? { command: match[1].toLowerCase(), argument: match[2]?.trim() ?? "" } : null;
|
|
5
|
+
return parseChannelCommand(text, { allowBotMention: false });
|
|
5
6
|
}
|
|
6
7
|
export function argumentFromDiscordInteraction(interaction) {
|
|
7
8
|
if (interaction.commandName === "prompt") {
|
|
@@ -16,17 +17,18 @@ export function argumentFromDiscordInteraction(interaction) {
|
|
|
16
17
|
return interaction.options.getString("value") ?? interaction.options.getString("query") ?? interaction.options.getString("thread_id") ?? "";
|
|
17
18
|
}
|
|
18
19
|
export function requiredPermissionForDiscordCommand(command, argument) {
|
|
19
|
-
|
|
20
|
+
const normalized = normalizeChannelCommandName(command);
|
|
21
|
+
if (normalized === "prompt")
|
|
20
22
|
return "prompt.send";
|
|
21
|
-
if (
|
|
23
|
+
if (normalized === "queue")
|
|
22
24
|
return argument.trim() ? "queue.write" : "queue.read";
|
|
23
|
-
return permissionForCommand(
|
|
25
|
+
return permissionForCommand(normalized);
|
|
24
26
|
}
|
|
25
27
|
export function isUnauthenticatedDiscordCommandAllowed(command) {
|
|
26
|
-
return command === "link";
|
|
28
|
+
return normalizeChannelCommandName(command) === "link";
|
|
27
29
|
}
|
|
28
30
|
export function permissionForDiscordAction(action) {
|
|
29
|
-
if (action.startsWith("discord_queue_"))
|
|
31
|
+
if (action.startsWith("discord_queue_") || action.startsWith("discord_peer_queue_"))
|
|
30
32
|
return "queue.write";
|
|
31
33
|
if (action.startsWith("discord_abort:"))
|
|
32
34
|
return "prompt.abort";
|
|
@@ -41,72 +43,8 @@ export function permissionForDiscordAction(action) {
|
|
|
41
43
|
return null;
|
|
42
44
|
}
|
|
43
45
|
export function discordCommands() {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
name,
|
|
47
|
-
description,
|
|
48
|
-
required,
|
|
49
|
-
});
|
|
50
|
-
return [
|
|
51
|
-
command("start", "Start or inspect the current NordRelay context"),
|
|
52
|
-
command("help", "Show Discord adapter help"),
|
|
53
|
-
command("prompt", "Send a prompt to the selected agent", [textOption("text", "Prompt text", true)]),
|
|
54
|
-
command("agent", "Select or show the active agent", [textOption("value", "Agent id")]),
|
|
55
|
-
command("auth", "Show selected agent auth status"),
|
|
56
|
-
command("login", "Start selected agent login"),
|
|
57
|
-
command("logout", "Sign out of the selected agent"),
|
|
58
|
-
command("session", "Show the active session"),
|
|
59
|
-
command("sessions", "Browse recent sessions", [textOption("query", "Search query")]),
|
|
60
|
-
command("new", "Create a new session", [textOption("value", "Workspace path")]),
|
|
61
|
-
command("switch", "Switch to a session", [textOption("thread_id", "Thread id", true)]),
|
|
62
|
-
command("attach", "Attach a session", [textOption("thread_id", "Thread id", true)]),
|
|
63
|
-
command("handback", "Hand the active session back to the native CLI"),
|
|
64
|
-
command("model", "Select or show models", [textOption("value", "Model id")]),
|
|
65
|
-
command("reasoning", "Select reasoning effort", [textOption("value", "Reasoning value")]),
|
|
66
|
-
command("effort", "Select reasoning effort", [textOption("value", "Reasoning value")]),
|
|
67
|
-
command("fast", "Toggle fast mode", [textOption("value", "on/off")]),
|
|
68
|
-
command("launch", "Select launch profile", [textOption("value", "Launch profile id")]),
|
|
69
|
-
command("launch_profiles", "Select launch profile", [textOption("value", "Launch profile id")]),
|
|
70
|
-
command("workspaces", "List allowed workspaces"),
|
|
71
|
-
command("pin", "Pin current or given thread", [textOption("value", "Thread id")]),
|
|
72
|
-
command("unpin", "Unpin current or given thread", [textOption("value", "Thread id")]),
|
|
73
|
-
command("pinned", "Show pinned threads"),
|
|
74
|
-
command("queue", "Show or manage queue", [textOption("action", "pause/resume/clear/run/cancel/top/up/down"), textOption("id", "Queue id")]),
|
|
75
|
-
command("clearqueue", "Clear queue"),
|
|
76
|
-
command("cancel", "Cancel queued prompt", [textOption("value", "Queue id", true)]),
|
|
77
|
-
command("abort", "Abort the active task"),
|
|
78
|
-
command("stop", "Abort the active task"),
|
|
79
|
-
command("retry", "Retry the last prompt"),
|
|
80
|
-
command("sync", "Sync from local agent state"),
|
|
81
|
-
command("activity", "Show recent activity", [textOption("value", "Limit")]),
|
|
82
|
-
command("tasks", "Show recent tasks", [textOption("value", "Limit")]),
|
|
83
|
-
command("progress", "Show current turn progress"),
|
|
84
|
-
command("audit", "Show recent audit events", [textOption("value", "Limit")]),
|
|
85
|
-
command("artifacts", "List or send artifacts", [textOption("value", "zip <turn-id>")]),
|
|
86
|
-
command("logs", "Show logs", [textOption("value", "Target and line count")]),
|
|
87
|
-
command("version", "Show versions"),
|
|
88
|
-
command("status", "Show status"),
|
|
89
|
-
command("health", "Show health"),
|
|
90
|
-
command("diagnostics", "Show diagnostics"),
|
|
91
|
-
command("support", "Show support diagnostics"),
|
|
92
|
-
command("restart", "Restart NordRelay"),
|
|
93
|
-
command("update", "Update NordRelay or agents", [
|
|
94
|
-
textOption("target", "jobs, install, log, cancel, input, or agent id"),
|
|
95
|
-
textOption("agent", "Agent id or job id"),
|
|
96
|
-
textOption("input", "Text for update input"),
|
|
97
|
-
]),
|
|
98
|
-
command("lock", "Lock this context"),
|
|
99
|
-
command("unlock", "Unlock this context"),
|
|
100
|
-
command("locks", "List locks"),
|
|
101
|
-
command("mirror", "Set mirror mode", [textOption("value", "off/status/final/full")]),
|
|
102
|
-
command("notify", "Set notification mode", [textOption("value", "off/minimal/all")]),
|
|
103
|
-
command("voice", "Show or change voice settings", [textOption("value", "transcribe-only on/off")]),
|
|
104
|
-
command("register_channel", "Enable this Discord channel for NordRelay"),
|
|
105
|
-
command("link", "Link this Discord account with a NordRelay code", [textOption("value", "Link code", true)]),
|
|
106
|
-
command("whoami", "Show linked NordRelay user"),
|
|
107
|
-
command("channels", "Show channel adapters"),
|
|
108
|
-
command("agents", "Show agent adapters"),
|
|
109
|
-
];
|
|
46
|
+
return discordCommandCatalog()
|
|
47
|
+
.map((entry) => command(entry.name, entry.description, entry.options));
|
|
110
48
|
}
|
|
111
49
|
function command(name, description, options = []) {
|
|
112
50
|
return {
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,8 @@ import { describeOpenClawCli, resolveOpenClawCli } from "./openclaw-cli.js";
|
|
|
19
19
|
import { installConsoleLogger } from "./logger.js";
|
|
20
20
|
import { checkPiAuthStatus } from "./pi-auth.js";
|
|
21
21
|
import { describePiCli, resolvePiCli } from "./pi-cli.js";
|
|
22
|
+
import { startPeerServer } from "./peer-server.js";
|
|
23
|
+
import { RelayRuntime } from "./relay-runtime.js";
|
|
22
24
|
import { configureRedaction } from "./redaction.js";
|
|
23
25
|
import { SessionRegistry } from "./session-registry.js";
|
|
24
26
|
import { UserStore } from "./user-management.js";
|
|
@@ -26,6 +28,8 @@ let registry;
|
|
|
26
28
|
let bot;
|
|
27
29
|
let discordBridge;
|
|
28
30
|
let webhookServer;
|
|
31
|
+
let peerServer;
|
|
32
|
+
let peerRuntime;
|
|
29
33
|
let runtimeConfig;
|
|
30
34
|
try {
|
|
31
35
|
const config = loadConfig();
|
|
@@ -39,6 +43,10 @@ try {
|
|
|
39
43
|
}
|
|
40
44
|
discordBridge = createDiscordBridge(config, registry);
|
|
41
45
|
await discordBridge?.start();
|
|
46
|
+
if (config.peerEnabled) {
|
|
47
|
+
peerRuntime = new RelayRuntime(config);
|
|
48
|
+
peerServer = await startPeerServer({ config, runtime: peerRuntime });
|
|
49
|
+
}
|
|
42
50
|
console.log("NordRelay running");
|
|
43
51
|
const userStore = new UserStore();
|
|
44
52
|
if (userStore.hasAdminUser()) {
|
|
@@ -52,6 +60,9 @@ try {
|
|
|
52
60
|
if (!authStatus.authenticated) {
|
|
53
61
|
console.warn(`Warning: ${agentLabel(config.defaultAgent)} is not authenticated. ${authStatus.detail}`);
|
|
54
62
|
}
|
|
63
|
+
for (const warning of config.adapterWarnings ?? []) {
|
|
64
|
+
console.warn(`Warning: ${warning}`);
|
|
65
|
+
}
|
|
55
66
|
console.log(`Workspace: ${config.workspace}`);
|
|
56
67
|
console.log(`Enabled agents: ${enabledAgents(config).join(", ")} (default: ${config.defaultAgent})`);
|
|
57
68
|
if (config.codexModel) {
|
|
@@ -83,6 +94,7 @@ try {
|
|
|
83
94
|
console.log("Session mode: per chat context");
|
|
84
95
|
console.log(`Telegram: ${config.telegramEnabled ? config.telegramTransport : "disabled"}`);
|
|
85
96
|
console.log(`Discord: ${config.discordEnabled ? "enabled" : "disabled"}`);
|
|
97
|
+
console.log(`Peers: ${peerServer ? peerServer.url : "disabled"}`);
|
|
86
98
|
await writeConnectorState({
|
|
87
99
|
status: "ready",
|
|
88
100
|
pid: Number(process.env.NORDRELAY_WRAPPER_PID) || process.pid,
|
|
@@ -99,6 +111,10 @@ try {
|
|
|
99
111
|
openClawGateway: config.openClawGatewayUrl,
|
|
100
112
|
telegramTransport: config.telegramTransport,
|
|
101
113
|
discordEnabled: config.discordEnabled,
|
|
114
|
+
peerEnabled: config.peerEnabled,
|
|
115
|
+
peerUrl: peerServer?.url,
|
|
116
|
+
peerTlsFingerprint: peerServer?.tlsFingerprint,
|
|
117
|
+
adapterWarnings: config.adapterWarnings ?? [],
|
|
102
118
|
});
|
|
103
119
|
}
|
|
104
120
|
catch (error) {
|
|
@@ -149,8 +165,12 @@ const shutdown = (signal) => {
|
|
|
149
165
|
console.warn("Failed to stop Discord bridge:", error instanceof Error ? error.message : String(error));
|
|
150
166
|
});
|
|
151
167
|
webhookServer?.close();
|
|
168
|
+
void peerServer?.close().catch((error) => {
|
|
169
|
+
console.warn("Failed to stop peer server:", error instanceof Error ? error.message : String(error));
|
|
170
|
+
});
|
|
152
171
|
setTimeout(() => {
|
|
153
172
|
registry?.disposeAll();
|
|
173
|
+
peerRuntime?.dispose();
|
|
154
174
|
void writeConnectorState({
|
|
155
175
|
status: "stopped",
|
|
156
176
|
pid: Number(process.env.NORDRELAY_WRAPPER_PID) || process.pid,
|
package/dist/metrics.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
1
2
|
import { getDiscordRateLimitMetrics } from "./discord-rate-limit.js";
|
|
2
3
|
import { getTelegramRateLimitMetrics } from "./telegram-rate-limit.js";
|
|
4
|
+
const startedAt = Date.now();
|
|
5
|
+
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
|
|
6
|
+
eventLoopDelay.enable();
|
|
3
7
|
export function buildRuntimeMetrics(input) {
|
|
4
8
|
const completedPromptDurations = input.activity
|
|
5
9
|
.filter((event) => event.category === "prompt" && event.status === "completed" && typeof event.durationMs === "number")
|
|
@@ -27,12 +31,54 @@ export function buildRuntimeMetrics(input) {
|
|
|
27
31
|
failed: countJobs(input.jobs, "failed"),
|
|
28
32
|
aborted: countJobs(input.jobs, "aborted"),
|
|
29
33
|
},
|
|
34
|
+
process: processMetrics(),
|
|
30
35
|
adapters: {
|
|
31
36
|
telegram: getTelegramRateLimitMetrics(),
|
|
32
37
|
discord: getDiscordRateLimitMetrics(),
|
|
33
38
|
},
|
|
34
39
|
};
|
|
35
40
|
}
|
|
41
|
+
function processMetrics() {
|
|
42
|
+
const memory = process.memoryUsage();
|
|
43
|
+
const cpu = process.cpuUsage();
|
|
44
|
+
const uptimeMs = Math.max(0, Math.round(process.uptime() * 1000));
|
|
45
|
+
const totalMs = Math.round((cpu.user + cpu.system) / 1000);
|
|
46
|
+
return {
|
|
47
|
+
pid: process.pid,
|
|
48
|
+
nodeVersion: process.version,
|
|
49
|
+
platform: process.platform,
|
|
50
|
+
arch: process.arch,
|
|
51
|
+
uptimeMs,
|
|
52
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
53
|
+
memory: {
|
|
54
|
+
rssBytes: memory.rss,
|
|
55
|
+
heapTotalBytes: memory.heapTotal,
|
|
56
|
+
heapUsedBytes: memory.heapUsed,
|
|
57
|
+
externalBytes: memory.external,
|
|
58
|
+
arrayBuffersBytes: memory.arrayBuffers,
|
|
59
|
+
},
|
|
60
|
+
cpu: {
|
|
61
|
+
userMs: Math.round(cpu.user / 1000),
|
|
62
|
+
systemMs: Math.round(cpu.system / 1000),
|
|
63
|
+
totalMs,
|
|
64
|
+
percentSinceStart: uptimeMs > 0 ? roundMetric((totalMs / uptimeMs) * 100) : null,
|
|
65
|
+
},
|
|
66
|
+
eventLoop: {
|
|
67
|
+
delayMeanMs: nanosecondsToMilliseconds(eventLoopDelay.mean),
|
|
68
|
+
delayMaxMs: nanosecondsToMilliseconds(eventLoopDelay.max),
|
|
69
|
+
delayP95Ms: nanosecondsToMilliseconds(eventLoopDelay.percentile(95)),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function nanosecondsToMilliseconds(value) {
|
|
74
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return roundMetric(value / 1_000_000);
|
|
78
|
+
}
|
|
79
|
+
function roundMetric(value) {
|
|
80
|
+
return Math.round(value * 100) / 100;
|
|
81
|
+
}
|
|
36
82
|
function countJobs(jobs, status) {
|
|
37
83
|
return jobs.filter((job) => job.status === status).length;
|
|
38
84
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
3
|
+
const NONCE_TTL_MS = 10 * 60 * 1000;
|
|
4
|
+
export class PeerNonceCache {
|
|
5
|
+
seen = new Map();
|
|
6
|
+
consume(peerId, nonce) {
|
|
7
|
+
const now = Date.now();
|
|
8
|
+
for (const [key, expiresAt] of this.seen.entries()) {
|
|
9
|
+
if (expiresAt <= now) {
|
|
10
|
+
this.seen.delete(key);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const key = `${peerId}:${nonce}`;
|
|
14
|
+
if (this.seen.has(key)) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
this.seen.set(key, now + NONCE_TTL_MS);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function signPeerRequest(peer, method, pathname, body = "") {
|
|
22
|
+
const timestamp = new Date().toISOString();
|
|
23
|
+
const nonce = randomBytes(16).toString("base64url");
|
|
24
|
+
const bodyHash = hashBody(body);
|
|
25
|
+
const signature = createSignature(peer.secret, canonicalRequest(method, pathname, timestamp, nonce, bodyHash));
|
|
26
|
+
return {
|
|
27
|
+
timestamp,
|
|
28
|
+
nonce,
|
|
29
|
+
bodyHash,
|
|
30
|
+
headers: {
|
|
31
|
+
"x-nordrelay-peer-id": peer.id,
|
|
32
|
+
"x-nordrelay-peer-timestamp": timestamp,
|
|
33
|
+
"x-nordrelay-peer-nonce": nonce,
|
|
34
|
+
"x-nordrelay-peer-body-sha256": bodyHash,
|
|
35
|
+
"x-nordrelay-peer-signature": signature,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function verifyPeerRequest(options) {
|
|
40
|
+
const timestamp = header(options.req, "x-nordrelay-peer-timestamp");
|
|
41
|
+
const nonce = header(options.req, "x-nordrelay-peer-nonce");
|
|
42
|
+
const bodyHash = header(options.req, "x-nordrelay-peer-body-sha256");
|
|
43
|
+
const signature = header(options.req, "x-nordrelay-peer-signature");
|
|
44
|
+
if (!timestamp || !nonce || !bodyHash || !signature) {
|
|
45
|
+
throw new Error("Missing peer authentication headers.");
|
|
46
|
+
}
|
|
47
|
+
const time = Date.parse(timestamp);
|
|
48
|
+
if (!Number.isFinite(time) || Math.abs(Date.now() - time) > MAX_CLOCK_SKEW_MS) {
|
|
49
|
+
throw new Error("Peer request timestamp is outside the allowed clock skew.");
|
|
50
|
+
}
|
|
51
|
+
if (hashBody(options.body) !== bodyHash) {
|
|
52
|
+
throw new Error("Peer request body hash mismatch.");
|
|
53
|
+
}
|
|
54
|
+
if (!options.nonces.consume(options.peer.id, nonce)) {
|
|
55
|
+
throw new Error("Replay detected for peer request.");
|
|
56
|
+
}
|
|
57
|
+
const expected = createSignature(options.peer.secret, canonicalRequest(options.method, options.pathname, timestamp, nonce, bodyHash));
|
|
58
|
+
if (!safeEqual(signature, expected)) {
|
|
59
|
+
throw new Error("Invalid peer request signature.");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function header(req, name) {
|
|
63
|
+
const value = req.headers[name.toLowerCase()];
|
|
64
|
+
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
|
65
|
+
}
|
|
66
|
+
export function hashBody(body) {
|
|
67
|
+
return createHash("sha256").update(body).digest("hex");
|
|
68
|
+
}
|
|
69
|
+
function canonicalRequest(method, pathname, timestamp, nonce, bodyHash) {
|
|
70
|
+
return [
|
|
71
|
+
method.toUpperCase(),
|
|
72
|
+
pathname,
|
|
73
|
+
timestamp,
|
|
74
|
+
nonce,
|
|
75
|
+
bodyHash,
|
|
76
|
+
].join("\n");
|
|
77
|
+
}
|
|
78
|
+
function createSignature(secret, value) {
|
|
79
|
+
return createHmac("sha256", secret).update(value).digest("base64url");
|
|
80
|
+
}
|
|
81
|
+
function safeEqual(left, right) {
|
|
82
|
+
const leftBuffer = Buffer.from(left);
|
|
83
|
+
const rightBuffer = Buffer.from(right);
|
|
84
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
85
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import { createPairingSignaturePayload, signPeerPayload, fingerprintForPublicKey, } from "./peer-identity.js";
|
|
4
|
+
import { signPeerRequest } from "./peer-auth.js";
|
|
5
|
+
import { PeerStore } from "./peer-store.js";
|
|
6
|
+
import { PEER_PROTOCOL_VERSION, } from "./peer-types.js";
|
|
7
|
+
export async function pairPeer(options, identity, store = new PeerStore()) {
|
|
8
|
+
const timestamp = new Date().toISOString();
|
|
9
|
+
const payload = createPairingSignaturePayload(identity.public.nodeId, timestamp, options.code);
|
|
10
|
+
const body = {
|
|
11
|
+
code: options.code,
|
|
12
|
+
name: options.name,
|
|
13
|
+
publicUrl: options.publicUrl,
|
|
14
|
+
identity: identity.public,
|
|
15
|
+
timestamp,
|
|
16
|
+
signature: signPeerPayload(identity.privateKey, payload),
|
|
17
|
+
};
|
|
18
|
+
const result = await requestJson({
|
|
19
|
+
url: joinPeerUrl(options.url, "/peer/pair"),
|
|
20
|
+
method: "POST",
|
|
21
|
+
body,
|
|
22
|
+
allowSelfSigned: true,
|
|
23
|
+
});
|
|
24
|
+
if (result.data.protocolVersion !== PEER_PROTOCOL_VERSION) {
|
|
25
|
+
throw new Error(`Unsupported peer protocol version: ${result.data.protocolVersion}`);
|
|
26
|
+
}
|
|
27
|
+
if (fingerprintForPublicKey(result.data.identity.publicKey) !== result.data.identity.fingerprint) {
|
|
28
|
+
throw new Error("Remote peer identity fingerprint does not match its public key.");
|
|
29
|
+
}
|
|
30
|
+
const peer = store.upsertPeer({
|
|
31
|
+
id: result.data.peerId,
|
|
32
|
+
name: result.data.identity.name,
|
|
33
|
+
url: normalizePeerUrl(options.url),
|
|
34
|
+
nodeId: result.data.identity.nodeId,
|
|
35
|
+
publicKey: result.data.identity.publicKey,
|
|
36
|
+
fingerprint: result.data.identity.fingerprint,
|
|
37
|
+
tlsFingerprint: result.tlsFingerprint,
|
|
38
|
+
secret: result.data.secret,
|
|
39
|
+
enabled: true,
|
|
40
|
+
direction: "outbound",
|
|
41
|
+
scopes: result.data.scopes,
|
|
42
|
+
allowedAgents: result.data.allowedAgents,
|
|
43
|
+
allowedWorkspaceRoots: result.data.allowedWorkspaceRoots,
|
|
44
|
+
workspaceAliases: result.data.workspaceAliases,
|
|
45
|
+
});
|
|
46
|
+
return { peer, tlsFingerprint: result.tlsFingerprint };
|
|
47
|
+
}
|
|
48
|
+
export class RemoteRelayClient {
|
|
49
|
+
store;
|
|
50
|
+
constructor(store = new PeerStore()) {
|
|
51
|
+
this.store = store;
|
|
52
|
+
}
|
|
53
|
+
async rpc(peerId, type, payload, actor) {
|
|
54
|
+
const peer = this.requiredPeer(peerId);
|
|
55
|
+
const body = {
|
|
56
|
+
protocolVersion: PEER_PROTOCOL_VERSION,
|
|
57
|
+
type,
|
|
58
|
+
payload,
|
|
59
|
+
actor,
|
|
60
|
+
};
|
|
61
|
+
const bodyText = JSON.stringify(body);
|
|
62
|
+
const signed = signPeerRequest(peer, "POST", "/peer/rpc", bodyText);
|
|
63
|
+
try {
|
|
64
|
+
const startedAt = Date.now();
|
|
65
|
+
const result = await requestJson({
|
|
66
|
+
url: joinPeerUrl(requiredPeerUrl(peer), "/peer/rpc"),
|
|
67
|
+
method: "POST",
|
|
68
|
+
bodyText,
|
|
69
|
+
headers: signed.headers,
|
|
70
|
+
expectedTlsFingerprint: peer.tlsFingerprint,
|
|
71
|
+
allowSelfSigned: Boolean(peer.tlsFingerprint),
|
|
72
|
+
});
|
|
73
|
+
this.store.markSeen(peer.id, healthPatchFromRpc(type, result.data.ok ? result.data.data : null, Date.now() - startedAt));
|
|
74
|
+
if (!result.data.ok) {
|
|
75
|
+
throw new Error(result.data.error);
|
|
76
|
+
}
|
|
77
|
+
return result.data.data;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
this.store.markError(peer.id, error instanceof Error ? error.message : String(error));
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async webProxy(peerId, payload, actor, sourceContextKey) {
|
|
85
|
+
return this.rpc(peerId, "web.proxy", sourceContextKey ? { ...payload, contextKey: sourceContextKey } : payload, actor);
|
|
86
|
+
}
|
|
87
|
+
subscribe(peerId, onEvent, onError, sourceContextKey) {
|
|
88
|
+
const peer = this.requiredPeer(peerId);
|
|
89
|
+
const url = new URL(joinPeerUrl(requiredPeerUrl(peer), "/peer/events"));
|
|
90
|
+
if (sourceContextKey) {
|
|
91
|
+
url.searchParams.set("contextKey", sourceContextKey);
|
|
92
|
+
}
|
|
93
|
+
const signed = signPeerRequest(peer, "GET", `${url.pathname}${url.search}`, "");
|
|
94
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
95
|
+
const req = transport.request({
|
|
96
|
+
method: "GET",
|
|
97
|
+
protocol: url.protocol,
|
|
98
|
+
hostname: url.hostname,
|
|
99
|
+
port: url.port,
|
|
100
|
+
path: `${url.pathname}${url.search}`,
|
|
101
|
+
headers: signed.headers,
|
|
102
|
+
rejectUnauthorized: false,
|
|
103
|
+
}, (res) => {
|
|
104
|
+
try {
|
|
105
|
+
assertTlsFingerprint(res.socket, peer.tlsFingerprint);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
req.destroy(error);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if ((res.statusCode ?? 500) >= 400) {
|
|
112
|
+
req.destroy(new Error(`Peer events failed with HTTP ${res.statusCode}`));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
this.store.markSeen(peer.id, { remoteStatus: "online" });
|
|
116
|
+
let buffer = "";
|
|
117
|
+
res.setEncoding("utf8");
|
|
118
|
+
res.on("data", (chunk) => {
|
|
119
|
+
buffer += chunk;
|
|
120
|
+
let separator = buffer.indexOf("\n\n");
|
|
121
|
+
while (separator !== -1) {
|
|
122
|
+
const frame = buffer.slice(0, separator);
|
|
123
|
+
buffer = buffer.slice(separator + 2);
|
|
124
|
+
const data = frame.split(/\n/).find((line) => line.startsWith("data:"))?.slice(5).trim();
|
|
125
|
+
if (data) {
|
|
126
|
+
try {
|
|
127
|
+
onEvent(JSON.parse(data));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Ignore malformed event frames from a broken peer.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
separator = buffer.indexOf("\n\n");
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
req.on("error", (error) => {
|
|
138
|
+
this.store.markError(peer.id, error.message);
|
|
139
|
+
onError?.(error);
|
|
140
|
+
});
|
|
141
|
+
req.end();
|
|
142
|
+
return {
|
|
143
|
+
close: () => req.destroy(),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
requiredPeer(peerId) {
|
|
147
|
+
const peer = this.store.get(peerId);
|
|
148
|
+
if (!peer) {
|
|
149
|
+
throw new Error("Peer not found.");
|
|
150
|
+
}
|
|
151
|
+
if (!peer.enabled) {
|
|
152
|
+
throw new Error("Peer is disabled.");
|
|
153
|
+
}
|
|
154
|
+
return peer;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function healthPatchFromRpc(type, data, latencyMs) {
|
|
158
|
+
if (type !== "peer.ping" || !data || typeof data !== "object") {
|
|
159
|
+
return { latencyMs, remoteStatus: "online" };
|
|
160
|
+
}
|
|
161
|
+
const record = data;
|
|
162
|
+
return {
|
|
163
|
+
latencyMs,
|
|
164
|
+
remoteVersion: typeof record.version === "string" ? record.version : undefined,
|
|
165
|
+
remoteStatus: typeof record.status === "string" ? record.status : "online",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
async function requestJson(options) {
|
|
169
|
+
const url = new URL(options.url);
|
|
170
|
+
const bodyText = options.bodyText ?? (options.body === undefined ? "" : JSON.stringify(options.body));
|
|
171
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
172
|
+
return await new Promise((resolve, reject) => {
|
|
173
|
+
const req = transport.request({
|
|
174
|
+
method: options.method,
|
|
175
|
+
protocol: url.protocol,
|
|
176
|
+
hostname: url.hostname,
|
|
177
|
+
port: url.port,
|
|
178
|
+
path: `${url.pathname}${url.search}`,
|
|
179
|
+
headers: {
|
|
180
|
+
"content-type": "application/json",
|
|
181
|
+
"content-length": Buffer.byteLength(bodyText),
|
|
182
|
+
...(options.headers ?? {}),
|
|
183
|
+
},
|
|
184
|
+
rejectUnauthorized: options.allowSelfSigned ? false : undefined,
|
|
185
|
+
}, (res) => {
|
|
186
|
+
let tlsFingerprint;
|
|
187
|
+
try {
|
|
188
|
+
tlsFingerprint = assertTlsFingerprint(res.socket, options.expectedTlsFingerprint);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
reject(error);
|
|
192
|
+
req.destroy();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const chunks = [];
|
|
196
|
+
res.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
197
|
+
res.on("end", () => {
|
|
198
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
199
|
+
let data = {};
|
|
200
|
+
try {
|
|
201
|
+
data = text ? JSON.parse(text) : {};
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
reject(new Error(`Peer returned invalid JSON: ${text.slice(0, 200)}`));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if ((res.statusCode ?? 500) >= 400) {
|
|
208
|
+
const message = data && typeof data === "object" && "error" in data ? String(data.error) : `HTTP ${res.statusCode}`;
|
|
209
|
+
reject(new Error(message));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
resolve({ data: data, tlsFingerprint });
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
req.on("error", reject);
|
|
216
|
+
if (bodyText)
|
|
217
|
+
req.write(bodyText);
|
|
218
|
+
req.end();
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function assertTlsFingerprint(socket, expected) {
|
|
222
|
+
if (!socket.encrypted) {
|
|
223
|
+
if (expected) {
|
|
224
|
+
throw new Error("Expected a TLS peer connection, but the peer used plaintext HTTP.");
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
const certificate = socket.getPeerCertificate();
|
|
229
|
+
const actual = normalizeFingerprint(certificate?.fingerprint256);
|
|
230
|
+
if (expected && actual !== normalizeFingerprint(expected)) {
|
|
231
|
+
throw new Error("Peer TLS certificate fingerprint mismatch.");
|
|
232
|
+
}
|
|
233
|
+
return actual;
|
|
234
|
+
}
|
|
235
|
+
function requiredPeerUrl(peer) {
|
|
236
|
+
if (!peer.url) {
|
|
237
|
+
throw new Error(`Peer ${peer.name} has no URL.`);
|
|
238
|
+
}
|
|
239
|
+
return peer.url;
|
|
240
|
+
}
|
|
241
|
+
function joinPeerUrl(base, route) {
|
|
242
|
+
const url = new URL(normalizePeerUrl(base));
|
|
243
|
+
url.pathname = route;
|
|
244
|
+
url.search = "";
|
|
245
|
+
return url.toString();
|
|
246
|
+
}
|
|
247
|
+
function normalizePeerUrl(value) {
|
|
248
|
+
const url = new URL(value);
|
|
249
|
+
url.pathname = "";
|
|
250
|
+
url.search = "";
|
|
251
|
+
url.hash = "";
|
|
252
|
+
return url.toString().replace(/\/$/, "");
|
|
253
|
+
}
|
|
254
|
+
function normalizeFingerprint(value) {
|
|
255
|
+
return value?.trim().toLowerCase();
|
|
256
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const DEFAULT_SOURCE_CONTEXT = "web:dashboard";
|
|
2
|
+
export function peerRuntimeContextKey(peer, sourceContextKey) {
|
|
3
|
+
const source = sourceContextKey?.trim() || DEFAULT_SOURCE_CONTEXT;
|
|
4
|
+
return `peer:${encodeContextPart(peer.id || peer.nodeId)}:${encodeContextPart(source)}`;
|
|
5
|
+
}
|
|
6
|
+
export function parsePeerRuntimeContextKey(key) {
|
|
7
|
+
const match = /^peer:([^:]+):(.+)$/.exec(key);
|
|
8
|
+
if (!match?.[1] || !match[2]) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
peerId: decodeContextPart(match[1]),
|
|
13
|
+
sourceContextKey: decodeContextPart(match[2]),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function encodeContextPart(value) {
|
|
17
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
18
|
+
}
|
|
19
|
+
function decodeContextPart(value) {
|
|
20
|
+
return Buffer.from(value, "base64url").toString("utf8");
|
|
21
|
+
}
|