@nordbyte/nordrelay 0.8.1 → 0.8.3
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 +9 -0
- package/README.md +84 -1205
- package/dist/{access-control.js → access/access-control.js} +1 -1
- package/dist/{audit-log.js → access/audit-log.js} +32 -15
- package/dist/{session-locks.js → access/session-locks.js} +1 -1
- package/dist/{user-management.js → access/user-management.js} +1 -1
- package/dist/{claude-code-cli.js → agents/claude-code/claude-code-cli.js} +2 -2
- package/dist/{claude-code-session.js → agents/claude-code/claude-code-session.js} +1 -1
- package/dist/{codex-cli.js → agents/codex/codex-cli.js} +14 -5
- package/dist/{codex-session.js → agents/codex/codex-session.js} +2 -4
- package/dist/{hermes-cli.js → agents/hermes/hermes-cli.js} +2 -2
- package/dist/{hermes-launch.js → agents/hermes/hermes-launch.js} +1 -1
- package/dist/{hermes-session.js → agents/hermes/hermes-session.js} +1 -1
- package/dist/{openclaw-cli.js → agents/openclaw/openclaw-cli.js} +2 -2
- package/dist/{openclaw-launch.js → agents/openclaw/openclaw-launch.js} +1 -1
- package/dist/{openclaw-session.js → agents/openclaw/openclaw-session.js} +1 -1
- package/dist/{pi-cli.js → agents/pi/pi-cli.js} +2 -2
- package/dist/{pi-launch.js → agents/pi/pi-launch.js} +1 -1
- package/dist/{pi-session.js → agents/pi/pi-session.js} +1 -1
- package/dist/{adapter-conformance.js → agents/shared/adapter-conformance.js} +2 -2
- package/dist/{agent-activity.js → agents/shared/agent-activity.js} +5 -5
- package/dist/agents/shared/agent-auth-commands.js +30 -0
- package/dist/{agent-factory.js → agents/shared/agent-factory.js} +5 -5
- package/dist/{agent-feature-matrix.js → agents/shared/agent-feature-matrix.js} +2 -2
- package/dist/{agent-updates.js → agents/shared/agent-updates.js} +7 -7
- package/dist/{discord-artifacts.js → channels/discord/discord-artifacts.js} +4 -4
- package/dist/{discord-bot.js → channels/discord/discord-bot.js} +176 -451
- package/dist/{discord-channel-runtime.js → channels/discord/discord-channel-runtime.js} +2 -2
- package/dist/{discord-command-surface.js → channels/discord/discord-command-surface.js} +3 -3
- package/dist/{bot-rendering.js → channels/shared/bot-rendering.js} +6 -6
- package/dist/{channel-actions.js → channels/shared/channel-actions.js} +4 -4
- package/dist/channels/shared/channel-bridge-controller.js +69 -0
- package/dist/channels/shared/channel-cli-artifacts.js +51 -0
- package/dist/{channel-command-service.js → channels/shared/channel-command-service.js} +51 -28
- package/dist/channels/shared/channel-external-mirror-controller.js +193 -0
- package/dist/channels/shared/channel-external-monitor.js +52 -0
- package/dist/{channel-mirror-registry.js → channels/shared/channel-mirror-registry.js} +14 -6
- package/dist/{channel-peer-prompt.js → channels/shared/channel-peer-prompt.js} +3 -3
- package/dist/channels/shared/channel-prompt-queue.js +37 -0
- package/dist/{channel-turn-service.js → channels/shared/channel-turn-service.js} +25 -11
- package/dist/{context-key.js → channels/shared/context-key.js} +1 -1
- package/dist/{session-format.js → channels/shared/session-format.js} +2 -2
- package/dist/{slack-artifacts.js → channels/slack/slack-artifacts.js} +4 -4
- package/dist/{slack-bot.js → channels/slack/slack-bot.js} +171 -309
- package/dist/{slack-channel-runtime.js → channels/slack/slack-channel-runtime.js} +2 -2
- package/dist/{slack-command-surface.js → channels/slack/slack-command-surface.js} +2 -2
- package/dist/{slack-diagnostics.js → channels/slack/slack-diagnostics.js} +2 -2
- package/dist/{bot-ui.js → channels/telegram/bot-ui.js} +1 -1
- package/dist/{bot.js → channels/telegram/bot.js} +195 -430
- package/dist/{telegram-access-commands.js → channels/telegram/telegram-access-commands.js} +3 -3
- package/dist/{telegram-access-middleware.js → channels/telegram/telegram-access-middleware.js} +4 -4
- package/dist/{telegram-agent-commands.js → channels/telegram/telegram-agent-commands.js} +9 -9
- package/dist/{telegram-artifact-commands.js → channels/telegram/telegram-artifact-commands.js} +4 -4
- package/dist/{telegram-channel-runtime.js → channels/telegram/telegram-channel-runtime.js} +2 -2
- package/dist/{telegram-command-menu.js → channels/telegram/telegram-command-menu.js} +1 -1
- package/dist/{telegram-diagnostics-command.js → channels/telegram/telegram-diagnostics-command.js} +7 -7
- package/dist/{telegram-general-commands.js → channels/telegram/telegram-general-commands.js} +4 -4
- package/dist/{telegram-operational-commands.js → channels/telegram/telegram-operational-commands.js} +5 -5
- package/dist/{telegram-output.js → channels/telegram/telegram-output.js} +2 -2
- package/dist/{telegram-preference-commands.js → channels/telegram/telegram-preference-commands.js} +3 -3
- package/dist/{telegram-queue-commands.js → channels/telegram/telegram-queue-commands.js} +6 -6
- package/dist/{telegram-support-command.js → channels/telegram/telegram-support-command.js} +4 -4
- package/dist/{telegram-update-commands.js → channels/telegram/telegram-update-commands.js} +5 -5
- package/dist/{config-metadata.js → core/config-metadata.js} +8 -0
- package/dist/{config.js → core/config.js} +11 -3
- package/dist/core/pagination.js +22 -0
- package/dist/index.js +27 -23
- package/dist/peers/peer-discovery-jobs.js +206 -0
- package/dist/peers/peer-discovery.js +223 -0
- package/dist/peers/peer-health-monitor.js +49 -0
- package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
- package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
- package/dist/{peer-server.js → peers/peer-server.js} +3 -2
- package/dist/{peer-store.js → peers/peer-store.js} +96 -9
- package/dist/{peer-types.js → peers/peer-types.js} +28 -0
- package/dist/peers/peer-web-proxy-contract.js +129 -0
- package/dist/{metrics.js → runtime/metrics.js} +5 -3
- package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
- package/dist/runtime/relay-auth-service.js +63 -0
- package/dist/runtime/relay-dashboard-service.js +139 -0
- package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +155 -53
- package/dist/{relay-queue-service.js → runtime/relay-queue-service.js} +1 -0
- package/dist/runtime/relay-runtime-active-sessions.js +387 -0
- package/dist/runtime/relay-runtime-dashboard.js +204 -0
- package/dist/{relay-runtime-helpers.js → runtime/relay-runtime-helpers.js} +3 -0
- package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +311 -0
- package/dist/runtime/relay-runtime-sessions.js +631 -0
- package/dist/runtime/relay-runtime-trace.js +92 -0
- package/dist/runtime/relay-runtime-types.js +1 -0
- package/dist/runtime/relay-runtime-updates-jobs.js +366 -0
- package/dist/runtime/relay-runtime.js +461 -0
- package/dist/runtime/runtime-cache.js +117 -0
- package/dist/{prompt-store.js → state/prompt-store.js} +13 -1
- package/dist/{session-registry.js → state/session-registry.js} +3 -3
- package/dist/{operations.js → support/operations.js} +7 -7
- package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
- package/dist/{web-api-contract.js → web/web-api-contract.js} +19 -3
- package/dist/web/web-api-types.js +1 -0
- package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +17 -14
- package/dist/{web-dashboard-artifact-routes.js → web/web-dashboard-artifact-routes.js} +6 -2
- package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +25 -2
- package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
- package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +95 -30
- package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +121 -7
- package/dist/{web-dashboard-runtime-routes.js → web/web-dashboard-runtime-routes.js} +8 -1
- package/dist/web/web-dashboard-security.js +14 -0
- package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +29 -13
- package/dist/web/web-dashboard-ui.js +56 -0
- package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
- package/dist/web/web-performance.js +62 -0
- package/dist/web/web-rate-limit.js +19 -0
- package/dist/{web-state.js → web/web-state.js} +107 -9
- package/dist/webui-assets/dashboard.css +398 -49
- package/dist/webui-assets/dashboard.js +1239 -103
- package/dist/webui-assets/favicon.ico +0 -0
- package/dist/webui-assets/favicon.png +0 -0
- package/dist/webui-assets/logo.png +0 -0
- package/package.json +6 -3
- package/plugins/nordrelay/scripts/nordrelay.mjs +346 -12
- package/plugins/nordrelay/scripts/service-installer.mjs +183 -0
- package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
- package/scripts/postinstall.mjs +122 -0
- package/dist/relay-runtime.js +0 -1916
- package/dist/runtime-cache.js +0 -57
- package/dist/web-dashboard-ui.js +0 -20
- /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
- /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
- /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
- /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
- /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
- /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
- /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
- /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
- /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
- /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
- /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
- /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
- /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
- /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
- /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
- /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
- /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
- /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
- /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
- /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
- /package/dist/{agent.js → agents/shared/agent.js} +0 -0
- /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
- /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
- /package/dist/{voice.js → artifacts/voice.js} +0 -0
- /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
- /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
- /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
- /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
- /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
- /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
- /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
- /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
- /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
- /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
- /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
- /package/dist/{activity-events.js → core/activity-events.js} +0 -0
- /package/dist/{error-messages.js → core/error-messages.js} +0 -0
- /package/dist/{format.js → core/format.js} +0 -0
- /package/dist/{logger.js → core/logger.js} +0 -0
- /package/dist/{redaction.js → core/redaction.js} +0 -0
- /package/dist/{settings-service.js → core/settings-service.js} +0 -0
- /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
- /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
- /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
- /package/dist/{peer-client.js → peers/peer-client.js} +0 -0
- /package/dist/{peer-context.js → peers/peer-context.js} +0 -0
- /package/dist/{peer-readiness.js → peers/peer-readiness.js} +0 -0
- /package/dist/{web-api-types.js → runtime/relay-runtime-delegate.js} +0 -0
- /package/dist/{remote-prompt.js → runtime/remote-prompt.js} +0 -0
- /package/dist/{bot-preferences.js → state/bot-preferences.js} +0 -0
- /package/dist/{job-store.js → state/job-store.js} +0 -0
- /package/dist/{persistence.js → state/persistence.js} +0 -0
- /package/dist/{state-backend.js → state/state-backend.js} +0 -0
- /package/dist/{zip-writer.js → support/zip-writer.js} +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { isPermission } from "
|
|
2
|
-
import { AGENT_IDS, isAgentId } from "
|
|
3
|
-
import { ensurePeerTlsFiles, loadOrCreatePeerIdentity, } from "
|
|
4
|
-
import { checkPeerEndpoint, pairPeer, RemoteRelayClient } from "
|
|
5
|
-
import { buildPeerReadiness, peerListenUrl } from "
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
1
|
+
import { isPermission } from "../access/access-control.js";
|
|
2
|
+
import { AGENT_IDS, isAgentId } from "../agents/shared/agent.js";
|
|
3
|
+
import { exportPeerIdentityBackup, ensurePeerTlsFiles, loadOrCreatePeerIdentity, restorePeerIdentityBackup, } from "../peers/peer-identity.js";
|
|
4
|
+
import { checkPeerEndpoint, checkPeerIdentityEndpoint, pairPeer, RemoteRelayClient } from "../peers/peer-client.js";
|
|
5
|
+
import { buildPeerReadiness, peerListenUrl } from "../peers/peer-readiness.js";
|
|
6
|
+
import { discoverLanPeers } from "../peers/peer-discovery.js";
|
|
7
|
+
import { PeerStore } from "../peers/peer-store.js";
|
|
8
|
+
import { publicPeer } from "../peers/peer-types.js";
|
|
8
9
|
import { arrayStringField, objectRecord, optionalBooleanField, optionalNumberField, optionalStringField, readJsonBody, sendJson, } from "./web-dashboard-http.js";
|
|
9
10
|
export async function handleDashboardPeerRoute(req, res, url, options) {
|
|
10
11
|
const store = new PeerStore(options.home);
|
|
@@ -25,6 +26,7 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
|
|
|
25
26
|
const readiness = await buildPeerReadiness(options.config);
|
|
26
27
|
const created = store.createInvitation({
|
|
27
28
|
name: optionalStringField(body, "name"),
|
|
29
|
+
group: optionalStringField(body, "group"),
|
|
28
30
|
expiresInMs: (optionalNumberField(body, "expiresMinutes") ?? 10) * 60 * 1000,
|
|
29
31
|
scopes: parseScopes(arrayStringField(body, "scopes")),
|
|
30
32
|
allowedAgents: parseAgents(arrayStringField(body, "allowedAgents")),
|
|
@@ -53,11 +55,63 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
|
|
|
53
55
|
if (peerId) {
|
|
54
56
|
const probe = await new RemoteRelayClient(store).rpc(peerId, "peer.probe", {}, options.activityActor);
|
|
55
57
|
sendJson(res, 200, { type: "remote", peerId, readiness, probe });
|
|
58
|
+
options.auditPeerAction?.("peer_probe", peerId);
|
|
56
59
|
return true;
|
|
57
60
|
}
|
|
58
61
|
const expectedTlsFingerprint = options.config.peerPublicUrl ? undefined : tls?.fingerprint;
|
|
59
62
|
const probe = await checkPeerEndpoint(readiness.listenUrl, { expectedTlsFingerprint });
|
|
60
63
|
sendJson(res, 200, { type: "local", readiness, probe });
|
|
64
|
+
options.auditPeerAction?.("peer_probe", readiness.listenUrl);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (req.method === "GET" && url.pathname === "/api/peers/discover") {
|
|
68
|
+
const result = await discoverLanPeers(options.config, discoveryOptionsFromQuery(url));
|
|
69
|
+
sendJson(res, 200, result);
|
|
70
|
+
options.auditPeerAction?.("peer_discovery_started", `sync scan ${result.scanned} targets`);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (req.method === "GET" && url.pathname === "/api/peers/discovery-jobs") {
|
|
74
|
+
sendJson(res, 200, { jobs: options.discoveryJobs?.list() ?? [] });
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (req.method === "POST" && url.pathname === "/api/peers/discovery-jobs") {
|
|
78
|
+
const body = await readJsonBody(req);
|
|
79
|
+
const job = await options.discoveryJobs.start(discoveryOptionsFromBody(body));
|
|
80
|
+
sendJson(res, 202, { job });
|
|
81
|
+
options.auditPeerAction?.("peer_discovery_started", job.id);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
const discoveryJobMatch = url.pathname.match(/^\/api\/peers\/discovery-jobs\/([^/]+)(?:\/(cancel|log))?$/);
|
|
85
|
+
if (discoveryJobMatch?.[1]) {
|
|
86
|
+
const id = decodeURIComponent(discoveryJobMatch[1]);
|
|
87
|
+
const action = discoveryJobMatch[2];
|
|
88
|
+
if (req.method === "GET" && action === "log") {
|
|
89
|
+
sendJson(res, 200, { id, plain: options.discoveryJobs?.log(id) ?? "" });
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (req.method === "POST" && action === "cancel") {
|
|
93
|
+
const job = options.discoveryJobs?.cancel(id);
|
|
94
|
+
sendJson(res, 200, { job });
|
|
95
|
+
options.auditPeerAction?.("peer_discovery_cancelled", id);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (req.method === "GET" && !action) {
|
|
99
|
+
sendJson(res, 200, { job: options.discoveryJobs?.get(id) ?? null });
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (req.method === "GET" && url.pathname === "/api/peers/identity/backup") {
|
|
104
|
+
const backup = exportPeerIdentityBackup(options.home, options.config.peerName);
|
|
105
|
+
sendJson(res, 200, { backup });
|
|
106
|
+
options.auditPeerAction?.("peer_identity_backup_exported", backup.identity.nodeId);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
if (req.method === "POST" && url.pathname === "/api/peers/identity/restore") {
|
|
110
|
+
const body = await readJsonBody(req);
|
|
111
|
+
const backup = objectRecord(body.backup);
|
|
112
|
+
const restored = restorePeerIdentityBackup(backup, options.home);
|
|
113
|
+
sendJson(res, 200, { identity: restored.public });
|
|
114
|
+
options.auditPeerAction?.("peer_identity_restored", restored.public.nodeId);
|
|
61
115
|
return true;
|
|
62
116
|
}
|
|
63
117
|
const invitationMatch = url.pathname.match(/^\/api\/peers\/invitations\/([^/]+)$/);
|
|
@@ -86,6 +140,7 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
|
|
|
86
140
|
const body = await readJsonBody(req);
|
|
87
141
|
const peer = store.updatePeer(decodeURIComponent(peerMatch[1]), {
|
|
88
142
|
name: optionalStringField(body, "name"),
|
|
143
|
+
group: optionalStringField(body, "group"),
|
|
89
144
|
url: optionalStringField(body, "url"),
|
|
90
145
|
enabled: optionalBooleanField(body, "enabled"),
|
|
91
146
|
scopes: body.scopes === undefined ? undefined : parseScopes(arrayStringField(body, "scopes")),
|
|
@@ -97,6 +152,44 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
|
|
|
97
152
|
options.auditPeerAction?.("peer_updated", `${peer.name} (${peer.id})`);
|
|
98
153
|
return true;
|
|
99
154
|
}
|
|
155
|
+
const repinMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/repin$/);
|
|
156
|
+
if (repinMatch?.[1] && req.method === "POST") {
|
|
157
|
+
const peerId = decodeURIComponent(repinMatch[1]);
|
|
158
|
+
const peer = store.get(peerId);
|
|
159
|
+
if (!peer?.url) {
|
|
160
|
+
throw new Error("Peer URL is required before TLS re-pin.");
|
|
161
|
+
}
|
|
162
|
+
const probe = await checkPeerIdentityEndpoint(peer.url, { timeoutMs: options.config.peerDiscoveryTimeoutMs });
|
|
163
|
+
if (!probe.ok || !probe.identity) {
|
|
164
|
+
throw new Error(`Peer identity could not be verified: ${probe.detail}`);
|
|
165
|
+
}
|
|
166
|
+
if (probe.identity.nodeId !== peer.nodeId || probe.identity.publicKey !== peer.publicKey || probe.identity.fingerprint !== peer.fingerprint) {
|
|
167
|
+
throw new Error("Peer identity changed. Re-pair this peer instead of re-pinning TLS.");
|
|
168
|
+
}
|
|
169
|
+
const updated = store.updatePeerTlsFingerprint(peer.id, probe.tlsFingerprint);
|
|
170
|
+
sendJson(res, 200, { peer: publicPeer(updated), probe });
|
|
171
|
+
options.auditPeerAction?.("peer_tls_repinned", `${updated.name} (${updated.id})`);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
const rotateMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/rotate$/);
|
|
175
|
+
if (rotateMatch?.[1] && req.method === "POST") {
|
|
176
|
+
const body = await readJsonBody(req);
|
|
177
|
+
const readiness = await buildPeerReadiness(options.config);
|
|
178
|
+
const created = store.createRotationInvitation(decodeURIComponent(rotateMatch[1]), {
|
|
179
|
+
expiresInMs: (optionalNumberField(body, "expiresMinutes") ?? 10) * 60 * 1000,
|
|
180
|
+
});
|
|
181
|
+
const command = `nordrelay peer add ${readiness.listenUrl} --code ${created.code}`;
|
|
182
|
+
sendJson(res, 201, {
|
|
183
|
+
peer: created.peer,
|
|
184
|
+
invitation: created.invitation,
|
|
185
|
+
code: created.code,
|
|
186
|
+
command,
|
|
187
|
+
readiness,
|
|
188
|
+
warnings: readiness.warnings,
|
|
189
|
+
});
|
|
190
|
+
options.auditPeerAction?.("peer_rotation_invite_created", `${created.peer.name} (${created.peer.id})`);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
100
193
|
if (req.method === "GET" && url.pathname === "/api/peers/global-sessions") {
|
|
101
194
|
const query = optionalStringField(Object.fromEntries(url.searchParams), "query") ?? "";
|
|
102
195
|
const agent = parseAgent(optionalStringField(Object.fromEntries(url.searchParams), "agent"));
|
|
@@ -152,6 +245,7 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
|
|
|
152
245
|
const peerId = decodeURIComponent(healthMatch[1]);
|
|
153
246
|
const data = await new RemoteRelayClient(store).rpc(peerId, "peer.ping", undefined, options.activityActor);
|
|
154
247
|
sendJson(res, 200, { data, peer: publicPeer(store.get(peerId)) });
|
|
248
|
+
options.auditPeerAction?.("peer_health_checked", peerId);
|
|
155
249
|
return true;
|
|
156
250
|
}
|
|
157
251
|
const eventsMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/events$/);
|
|
@@ -189,6 +283,26 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
|
|
|
189
283
|
function parseScopes(values) {
|
|
190
284
|
return values.filter(isPermission);
|
|
191
285
|
}
|
|
286
|
+
function discoveryOptionsFromQuery(url) {
|
|
287
|
+
return {
|
|
288
|
+
targets: url.searchParams.getAll("target").concat((url.searchParams.get("targets") ?? "").split(/[\n,]/)).map((value) => value.trim()).filter(Boolean),
|
|
289
|
+
timeoutMs: optionalPositiveNumber(url.searchParams.get("timeoutMs")),
|
|
290
|
+
concurrency: optionalPositiveNumber(url.searchParams.get("concurrency")),
|
|
291
|
+
maxHosts: optionalPositiveNumber(url.searchParams.get("maxHosts")),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function discoveryOptionsFromBody(body) {
|
|
295
|
+
return {
|
|
296
|
+
targets: arrayStringField(body, "targets"),
|
|
297
|
+
timeoutMs: optionalNumberField(body, "timeoutMs"),
|
|
298
|
+
concurrency: optionalNumberField(body, "concurrency"),
|
|
299
|
+
maxHosts: optionalNumberField(body, "maxHosts"),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function optionalPositiveNumber(value) {
|
|
303
|
+
const parsed = Number(value);
|
|
304
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
305
|
+
}
|
|
192
306
|
function parseAgents(values) {
|
|
193
307
|
const parsed = values.filter(isAgentId);
|
|
194
308
|
return parsed.length > 0 ? parsed : [...AGENT_IDS];
|
|
@@ -63,7 +63,14 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
|
|
|
63
63
|
return true;
|
|
64
64
|
}
|
|
65
65
|
if (req.method === "GET" && url.pathname === "/api/jobs") {
|
|
66
|
-
sendJson(res, 200, await runtime.jobs(
|
|
66
|
+
sendJson(res, 200, await runtime.jobs({
|
|
67
|
+
limit: numberParam(url, "limit", 100),
|
|
68
|
+
cursor: url.searchParams.get("cursor") || undefined,
|
|
69
|
+
}));
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (req.method === "GET" && url.pathname === "/api/trace") {
|
|
73
|
+
sendJson(res, 200, await runtime.trace(stringField(Object.fromEntries(url.searchParams), "correlationId")));
|
|
67
74
|
return true;
|
|
68
75
|
}
|
|
69
76
|
const jobLogMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/log$/);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
export function createCspNonce() {
|
|
3
|
+
return randomBytes(16).toString("base64url");
|
|
4
|
+
}
|
|
5
|
+
export function requiresWebCsrf(method, pathname) {
|
|
6
|
+
const verb = (method ?? "GET").toUpperCase();
|
|
7
|
+
if (verb === "GET" || verb === "HEAD" || verb === "OPTIONS") {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
return pathname.startsWith("/api/");
|
|
11
|
+
}
|
|
12
|
+
export function isMutatingWebApiRequest(method, pathname) {
|
|
13
|
+
return requiresWebCsrf(method, pathname);
|
|
14
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { isAgentId } from "
|
|
1
|
+
import { isAgentId } from "../agents/shared/agent.js";
|
|
2
2
|
import { numberParam, optionalBooleanField, optionalStringField, parseUploadFiles, readJsonBody, requiredSearch, sendJson, stringField, } from "./web-dashboard-http.js";
|
|
3
|
+
import { cursorPage, normalizeCursorLimit } from "../core/pagination.js";
|
|
3
4
|
export async function handleDashboardSessionRoute(req, res, url, options) {
|
|
4
5
|
const { runtime, authUser } = options;
|
|
5
6
|
if (req.method === "GET" && url.pathname === "/api/locks") {
|
|
@@ -190,25 +191,40 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
|
|
|
190
191
|
sendJson(res, 200, { messages: await runtime.chatHistory(numberParam(url, "limit", 200)) });
|
|
191
192
|
return true;
|
|
192
193
|
}
|
|
194
|
+
if (req.method === "GET" && url.pathname === "/api/chat/mirror") {
|
|
195
|
+
await options.assertCurrentSessionScope(authUser);
|
|
196
|
+
sendJson(res, 200, await runtime.webMirrorPreference(""));
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
if (req.method === "POST" && url.pathname === "/api/chat/mirror") {
|
|
200
|
+
const body = await readJsonBody(req);
|
|
201
|
+
await options.assertCurrentSessionScope(authUser);
|
|
202
|
+
sendJson(res, 200, await runtime.webMirrorPreference(optionalStringField(body, "argument") ?? optionalStringField(body, "mode") ?? "", options.activityActor));
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
193
205
|
if (req.method === "DELETE" && url.pathname === "/api/chat/history") {
|
|
194
206
|
await options.assertCurrentSessionScope(authUser);
|
|
195
207
|
sendJson(res, 200, await runtime.clearChatHistory(options.activityActor));
|
|
196
208
|
return true;
|
|
197
209
|
}
|
|
198
210
|
if (req.method === "GET" && url.pathname === "/api/activity") {
|
|
211
|
+
const limit = normalizeCursorLimit(numberParam(url, "limit", 100), 100, 500);
|
|
212
|
+
const scoped = options.filterActivityByScope(authUser, runtime.activity({
|
|
213
|
+
limit: 500,
|
|
214
|
+
source: (url.searchParams.get("source") || "all"),
|
|
215
|
+
status: (url.searchParams.get("status") || "all"),
|
|
216
|
+
category: (url.searchParams.get("category") || "all"),
|
|
217
|
+
actor: url.searchParams.get("actor") || undefined,
|
|
218
|
+
agentId: url.searchParams.get("agent") || "all",
|
|
219
|
+
threadId: url.searchParams.get("thread") || undefined,
|
|
220
|
+
workspace: url.searchParams.get("workspace") || undefined,
|
|
221
|
+
type: url.searchParams.get("type") || undefined,
|
|
222
|
+
since: url.searchParams.get("since") || undefined,
|
|
223
|
+
}));
|
|
224
|
+
const scopedPage = cursorPage(scoped, url.searchParams.get("cursor") || undefined, limit, (event) => event.id);
|
|
199
225
|
sendJson(res, 200, {
|
|
200
|
-
events:
|
|
201
|
-
|
|
202
|
-
source: (url.searchParams.get("source") || "all"),
|
|
203
|
-
status: (url.searchParams.get("status") || "all"),
|
|
204
|
-
category: (url.searchParams.get("category") || "all"),
|
|
205
|
-
actor: url.searchParams.get("actor") || undefined,
|
|
206
|
-
agentId: url.searchParams.get("agent") || "all",
|
|
207
|
-
threadId: url.searchParams.get("thread") || undefined,
|
|
208
|
-
workspace: url.searchParams.get("workspace") || undefined,
|
|
209
|
-
type: url.searchParams.get("type") || undefined,
|
|
210
|
-
since: url.searchParams.get("since") || undefined,
|
|
211
|
-
})),
|
|
226
|
+
events: scopedPage.items,
|
|
227
|
+
pagination: scopedPage.pagination,
|
|
212
228
|
});
|
|
213
229
|
return true;
|
|
214
230
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const DASHBOARD_PRIMARY_NAV_PAGES = [
|
|
2
|
+
{ id: "overview", label: "Overview", permission: "inspect" },
|
|
3
|
+
{ id: "chat", label: "Chat", permission: "sessions.read" },
|
|
4
|
+
{ id: "sessions", label: "Sessions", permission: "sessions.read" },
|
|
5
|
+
{ id: "queue", label: "Queue", permission: "queue.read" },
|
|
6
|
+
{ id: "tasks", label: "Tasks", permission: "inspect" },
|
|
7
|
+
{ id: "activity", label: "Activity", permission: "sessions.read" },
|
|
8
|
+
{ id: "trace", label: "Trace", permission: "sessions.read" },
|
|
9
|
+
{ id: "artifacts", label: "Artifacts", permission: "files.read" },
|
|
10
|
+
];
|
|
11
|
+
export const DASHBOARD_NAV_SECTIONS = [
|
|
12
|
+
{
|
|
13
|
+
id: "operations",
|
|
14
|
+
label: "Operations",
|
|
15
|
+
pages: [
|
|
16
|
+
{ id: "metrics", label: "Metrics", permission: "inspect" },
|
|
17
|
+
{ id: "adapters", label: "Adapters", permission: "inspect" },
|
|
18
|
+
{ id: "version", label: "Version", permission: "inspect" },
|
|
19
|
+
{ id: "logs", label: "Logs", permission: "logs.read" },
|
|
20
|
+
{ id: "diagnostics", label: "Diagnostics", permission: "diagnostics.read" },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "administration",
|
|
25
|
+
label: "Administration",
|
|
26
|
+
pages: [
|
|
27
|
+
{ id: "access", label: "Users", permission: "users.read" },
|
|
28
|
+
{ id: "settings", label: "Settings", permission: "settings.read" },
|
|
29
|
+
{ id: "peers", label: "Peers", permission: "peers.read" },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
export const DASHBOARD_PAGES = [
|
|
34
|
+
...DASHBOARD_PRIMARY_NAV_PAGES,
|
|
35
|
+
...DASHBOARD_NAV_SECTIONS.flatMap((section) => section.pages),
|
|
36
|
+
];
|
|
37
|
+
function renderDashboardPageButton(page, activePage) {
|
|
38
|
+
return `<button type="button" data-page="${page.id}" data-permission="${page.permission}"${page.id === activePage ? ' class="active"' : ""}>${page.label}</button>`;
|
|
39
|
+
}
|
|
40
|
+
function renderDashboardNavSection(section, activePage) {
|
|
41
|
+
const isOpen = section.defaultOpen === true || section.pages.some((page) => page.id === activePage);
|
|
42
|
+
const itemsId = `nav-section-${section.id}`;
|
|
43
|
+
return `<div class="nav-section" data-nav-section="${section.id}" data-nav-open="${isOpen ? "true" : "false"}" data-nav-default-open="${section.defaultOpen === true ? "true" : "false"}">
|
|
44
|
+
<button type="button" class="nav-section-toggle" data-nav-toggle="${section.id}" aria-expanded="${isOpen ? "true" : "false"}" aria-controls="${itemsId}">${section.label}</button>
|
|
45
|
+
<div class="nav-section-items" id="${itemsId}"${isOpen ? "" : " hidden"}>
|
|
46
|
+
${section.pages.map((page) => renderDashboardPageButton(page, activePage)).join("\n ")}
|
|
47
|
+
</div>
|
|
48
|
+
</div>`;
|
|
49
|
+
}
|
|
50
|
+
export function renderDashboardNav(activePage = "overview") {
|
|
51
|
+
const primary = `<div class="nav-primary">
|
|
52
|
+
${DASHBOARD_PRIMARY_NAV_PAGES.map((page) => renderDashboardPageButton(page, activePage)).join("\n ")}
|
|
53
|
+
</div>`;
|
|
54
|
+
const sections = DASHBOARD_NAV_SECTIONS.map((section) => renderDashboardNavSection(section, activePage)).join("\n ");
|
|
55
|
+
return `${primary}\n ${sections}`;
|
|
56
|
+
}
|
|
@@ -1,47 +1,68 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { URL } from "node:url";
|
|
6
|
-
import { enabledAgents } from "
|
|
7
|
-
import { buildAdapterConformanceMatrix } from "
|
|
8
|
-
import { listAgentAdapterDescriptors } from "
|
|
9
|
-
import { isAgentId } from "
|
|
10
|
-
import { AuditLogStore } from "
|
|
11
|
-
import { listChannelDescriptors } from "
|
|
12
|
-
import { permissionForWebRequest } from "
|
|
13
|
-
import { loadConfig } from "
|
|
14
|
-
import { friendlyErrorText } from "
|
|
15
|
-
import { RelayRuntime } from "
|
|
16
|
-
import { resolveDashboardEnvPath, SettingsService } from "
|
|
17
|
-
import { mergeSettingsWizardTestSettings, runSettingsWizardTest } from "
|
|
18
|
-
import { UserStore, publicUser } from "
|
|
6
|
+
import { enabledAgents } from "../agents/shared/agent-factory.js";
|
|
7
|
+
import { buildAdapterConformanceMatrix } from "../agents/shared/adapter-conformance.js";
|
|
8
|
+
import { listAgentAdapterDescriptors } from "../agents/shared/agent-adapter.js";
|
|
9
|
+
import { isAgentId } from "../agents/shared/agent.js";
|
|
10
|
+
import { AuditLogStore } from "../access/audit-log.js";
|
|
11
|
+
import { listChannelDescriptors } from "../channels/shared/channel-adapter.js";
|
|
12
|
+
import { permissionForWebRequest } from "../access/access-control.js";
|
|
13
|
+
import { loadConfig } from "../core/config.js";
|
|
14
|
+
import { friendlyErrorText } from "../core/error-messages.js";
|
|
15
|
+
import { RelayRuntime } from "../runtime/relay-runtime.js";
|
|
16
|
+
import { resolveDashboardEnvPath, SettingsService } from "../core/settings-service.js";
|
|
17
|
+
import { mergeSettingsWizardTestSettings, runSettingsWizardTest } from "../core/settings-wizard-test.js";
|
|
18
|
+
import { UserStore, publicUser } from "../access/user-management.js";
|
|
19
19
|
import { handleDashboardAccessRoute } from "./web-dashboard-access-routes.js";
|
|
20
20
|
import { handleDashboardArtifactRoute } from "./web-dashboard-artifact-routes.js";
|
|
21
|
-
import { dashboardCss, dashboardJs } from "./web-dashboard-assets.js";
|
|
22
|
-
import { objectRecord, optionalStringField, parseCookies, readJsonBody, sendJson, sendText, } from "./web-dashboard-http.js";
|
|
21
|
+
import { dashboardCss, dashboardJs, dashboardStaticAsset } from "./web-dashboard-assets.js";
|
|
22
|
+
import { objectRecord, optionalStringField, parseCookies, readJsonBody, sendJson, sendText, sendStaticFile, isRequestBodyTooLargeError, } from "./web-dashboard-http.js";
|
|
23
23
|
import { renderDashboardApp, renderFirstRunSetupPage, renderLoginPage } from "./web-dashboard-pages.js";
|
|
24
24
|
import { handleDashboardRuntimeRoute } from "./web-dashboard-runtime-routes.js";
|
|
25
25
|
import { handleDashboardSessionRoute } from "./web-dashboard-session-routes.js";
|
|
26
26
|
import { handleDashboardPeerRoute } from "./web-dashboard-peer-routes.js";
|
|
27
|
+
import { PeerDiscoveryJobManager } from "../peers/peer-discovery-jobs.js";
|
|
28
|
+
import { recordWebApiMetric } from "./web-performance.js";
|
|
29
|
+
import { createCspNonce, isMutatingWebApiRequest, requiresWebCsrf } from "./web-dashboard-security.js";
|
|
30
|
+
import { consumeRateLimit, resetRateLimit } from "./web-rate-limit.js";
|
|
27
31
|
const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
|
|
32
|
+
const WEB_API_MUTATION_LIMIT = 240;
|
|
33
|
+
const WEB_API_MUTATION_WINDOW_MS = 60_000;
|
|
34
|
+
const WEB_API_MUTATION_BLOCK_MS = 60_000;
|
|
28
35
|
const options = parseOptions(process.argv.slice(2));
|
|
29
36
|
const config = loadConfig();
|
|
30
37
|
const runtime = new RelayRuntime(config);
|
|
31
38
|
const settings = new SettingsService(resolveDashboardEnvPath(options.home));
|
|
32
39
|
const users = new UserStore(options.home);
|
|
33
40
|
const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
|
|
41
|
+
const peerDiscoveryJobs = new PeerDiscoveryJobManager(config, options.home);
|
|
34
42
|
const loginAttempts = new Map();
|
|
43
|
+
const apiMutationAttempts = new Map();
|
|
35
44
|
const firstRunSetupToken = users.hasAdminUser() ? undefined : randomBytes(18).toString("base64url");
|
|
36
45
|
const firstRunSetupRequiresToken = !isLoopbackHost(options.host);
|
|
46
|
+
const csrfSecret = randomBytes(32).toString("base64url");
|
|
37
47
|
if (firstRunSetupToken) {
|
|
38
48
|
console.log(`NordRelay first-run setup token: ${firstRunSetupToken}`);
|
|
39
49
|
}
|
|
40
50
|
class AccessDeniedError extends Error {
|
|
41
51
|
}
|
|
42
52
|
const server = createServer((req, res) => {
|
|
53
|
+
const startedAt = Date.now();
|
|
54
|
+
const pathName = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`).pathname;
|
|
55
|
+
res.on("finish", () => {
|
|
56
|
+
recordWebApiMetric({
|
|
57
|
+
method: req.method ?? "GET",
|
|
58
|
+
path: pathName,
|
|
59
|
+
statusCode: res.statusCode,
|
|
60
|
+
durationMs: Date.now() - startedAt,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
43
63
|
void handleRequest(req, res).catch((error) => {
|
|
44
|
-
|
|
64
|
+
const status = error instanceof AccessDeniedError ? 403 : isRequestBodyTooLargeError(error) ? 413 : 500;
|
|
65
|
+
sendJson(res, status, { error: friendlyErrorText(error) });
|
|
45
66
|
});
|
|
46
67
|
});
|
|
47
68
|
await new Promise((resolve) => server.listen(options.port, options.host, resolve));
|
|
@@ -62,27 +83,52 @@ async function handleRequest(req, res) {
|
|
|
62
83
|
handleLogout(req, res);
|
|
63
84
|
return;
|
|
64
85
|
}
|
|
86
|
+
if (servePublicDashboardAsset(url.pathname, res)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
65
89
|
const authenticated = authenticateRequest(req);
|
|
66
90
|
if (url.pathname === "/api/auth/me" && req.method === "GET") {
|
|
67
91
|
if (!authenticated) {
|
|
68
92
|
sendJson(res, 401, { error: "Authentication required", adminConfigured: users.hasAdminUser() });
|
|
69
93
|
return;
|
|
70
94
|
}
|
|
71
|
-
sendJson(res, 200, currentUserDto(authenticated));
|
|
95
|
+
sendJson(res, 200, currentUserDto(authenticated, req));
|
|
72
96
|
return;
|
|
73
97
|
}
|
|
74
98
|
if (!authenticated) {
|
|
75
99
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
100
|
+
const cspNonce = createCspNonce();
|
|
76
101
|
if (!users.hasAdminUser()) {
|
|
77
|
-
sendText(res, 200, renderFirstRunSetupPage({ tokenRequired: firstRunSetupRequiresToken || !isLoopbackRequest(req) }), "text/html; charset=utf-8");
|
|
102
|
+
sendText(res, 200, renderFirstRunSetupPage({ tokenRequired: firstRunSetupRequiresToken || !isLoopbackRequest(req), cspNonce }), "text/html; charset=utf-8", { cspNonce });
|
|
78
103
|
return;
|
|
79
104
|
}
|
|
80
|
-
sendText(res, 200, renderLoginPage({ adminConfigured: users.hasAdminUser() }), "text/html; charset=utf-8");
|
|
105
|
+
sendText(res, 200, renderLoginPage({ adminConfigured: users.hasAdminUser(), cspNonce }), "text/html; charset=utf-8", { cspNonce });
|
|
81
106
|
return;
|
|
82
107
|
}
|
|
83
108
|
sendJson(res, 401, { error: "Authentication required", adminConfigured: users.hasAdminUser() });
|
|
84
109
|
return;
|
|
85
110
|
}
|
|
111
|
+
if (isMutatingWebApiRequest(req.method, url.pathname)) {
|
|
112
|
+
const limited = consumeRateLimit(apiMutationAttempts, `${req.socket.remoteAddress ?? "unknown"}:${authenticated.user.id}`, WEB_API_MUTATION_LIMIT, WEB_API_MUTATION_WINDOW_MS, WEB_API_MUTATION_BLOCK_MS);
|
|
113
|
+
if (limited.limited) {
|
|
114
|
+
sendJson(res, 429, { error: "Too many API changes. Try again later.", retryAfterMs: limited.retryAfterMs });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (requiresCsrf(req, url) && !verifyCsrf(req)) {
|
|
119
|
+
audit({
|
|
120
|
+
action: "permission_denied",
|
|
121
|
+
status: "denied",
|
|
122
|
+
channelId: "web",
|
|
123
|
+
contextKey: "web",
|
|
124
|
+
actor: webActivityActor(authenticated),
|
|
125
|
+
actorId: authenticated.user.id,
|
|
126
|
+
actorRole: authenticated.groups.map((group) => group.name).join(", "),
|
|
127
|
+
description: `Invalid CSRF token for ${req.method ?? "GET"} ${url.pathname}`,
|
|
128
|
+
});
|
|
129
|
+
sendJson(res, 403, { error: "Invalid CSRF token." });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
86
132
|
if (url.pathname === "/healthz") {
|
|
87
133
|
if (!users.hasPermission(authenticated, "inspect")) {
|
|
88
134
|
sendText(res, 403, "access denied\n", "text/plain; charset=utf-8");
|
|
@@ -92,7 +138,8 @@ async function handleRequest(req, res) {
|
|
|
92
138
|
return;
|
|
93
139
|
}
|
|
94
140
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
95
|
-
|
|
141
|
+
const cspNonce = createCspNonce();
|
|
142
|
+
sendText(res, 200, renderDashboardApp({ cspNonce }), "text/html; charset=utf-8", { cspNonce });
|
|
96
143
|
return;
|
|
97
144
|
}
|
|
98
145
|
if (url.pathname === "/assets/dashboard.css") {
|
|
@@ -113,6 +160,25 @@ async function handleRequest(req, res) {
|
|
|
113
160
|
}
|
|
114
161
|
await handleApi(req, res, url, authenticated);
|
|
115
162
|
}
|
|
163
|
+
function servePublicDashboardAsset(pathname, res) {
|
|
164
|
+
const assetName = pathname === "/favicon.ico"
|
|
165
|
+
? "favicon.ico"
|
|
166
|
+
: pathname === "/assets/favicon.png"
|
|
167
|
+
? "favicon.png"
|
|
168
|
+
: pathname === "/assets/logo.png"
|
|
169
|
+
? "logo.png"
|
|
170
|
+
: null;
|
|
171
|
+
if (!assetName) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
const asset = dashboardStaticAsset(assetName);
|
|
175
|
+
if (!asset) {
|
|
176
|
+
sendText(res, 404, "not found\n", "text/plain; charset=utf-8");
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
sendStaticFile(res, asset.filePath, asset.contentType);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
116
182
|
async function handleApi(req, res, url, authUser) {
|
|
117
183
|
const permission = permissionForWebRequest(req.method, url.pathname);
|
|
118
184
|
if (!permission) {
|
|
@@ -160,7 +226,7 @@ async function handleApi(req, res, url, authUser) {
|
|
|
160
226
|
if (req.method === "GET" && url.pathname === "/api/bootstrap") {
|
|
161
227
|
await assertCurrentSessionScope(authUser);
|
|
162
228
|
sendJson(res, 200, {
|
|
163
|
-
auth: currentUserDto(authUser),
|
|
229
|
+
auth: currentUserDto(authUser, req),
|
|
164
230
|
channels: listChannelDescriptors(),
|
|
165
231
|
agentAdapters: listAgentAdapterDescriptors().filter((adapter) => users.canUseAgent(authUser, adapter.id)),
|
|
166
232
|
adapterConformance: scopedAdapterConformance(authUser),
|
|
@@ -192,6 +258,7 @@ async function handleApi(req, res, url, authUser) {
|
|
|
192
258
|
config,
|
|
193
259
|
home: options.home,
|
|
194
260
|
runtime,
|
|
261
|
+
discoveryJobs: peerDiscoveryJobs,
|
|
195
262
|
activityActor: webActivityActor(authUser),
|
|
196
263
|
auditPeerAction: (action, description) => auditUserAction(authUser, action, description),
|
|
197
264
|
})) {
|
|
@@ -330,8 +397,8 @@ async function handleFirstRunSetup(req, res) {
|
|
|
330
397
|
actor: webActivityActor(authUser),
|
|
331
398
|
detail: authUser.user.email,
|
|
332
399
|
});
|
|
333
|
-
setSessionCookie(res, session.token);
|
|
334
|
-
sendJson(res, 201, currentUserDto(authUser));
|
|
400
|
+
setSessionCookie(res, session.token, req);
|
|
401
|
+
sendJson(res, 201, currentUserDto(authUser, undefined, session.token));
|
|
335
402
|
}
|
|
336
403
|
async function handleLogin(req, res) {
|
|
337
404
|
const body = await readJsonBody(req);
|
|
@@ -387,8 +454,8 @@ async function handleLogin(req, res) {
|
|
|
387
454
|
actor: webActivityActor(authUser),
|
|
388
455
|
detail: authUser.user.email,
|
|
389
456
|
});
|
|
390
|
-
setSessionCookie(res, session.token);
|
|
391
|
-
sendJson(res, 200, currentUserDto(authUser));
|
|
457
|
+
setSessionCookie(res, session.token, req);
|
|
458
|
+
sendJson(res, 200, currentUserDto(authUser, undefined, session.token));
|
|
392
459
|
}
|
|
393
460
|
function isLoopbackRequest(req) {
|
|
394
461
|
const address = req.socket.remoteAddress ?? "";
|
|
@@ -402,6 +469,10 @@ function isLoopbackHost(host) {
|
|
|
402
469
|
}
|
|
403
470
|
function handleLogout(req, res) {
|
|
404
471
|
const authUser = authenticateRequest(req);
|
|
472
|
+
if (authUser && !verifyCsrf(req)) {
|
|
473
|
+
sendJson(res, 403, { error: "Invalid CSRF token." });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
405
476
|
users.destroyWebSession(parseCookies(req.headers.cookie ?? "").nr_session);
|
|
406
477
|
if (authUser) {
|
|
407
478
|
auditUserAction(authUser, "auth_logout", authUser.user.email);
|
|
@@ -431,19 +502,49 @@ function authenticateRequest(req) {
|
|
|
431
502
|
const cookies = parseCookies(req.headers.cookie ?? "");
|
|
432
503
|
return users.resolveWebSession(cookies.nr_session);
|
|
433
504
|
}
|
|
434
|
-
function setSessionCookie(res, token) {
|
|
435
|
-
|
|
505
|
+
function setSessionCookie(res, token, req) {
|
|
506
|
+
const secure = req && isHttpsRequest(req) ? "; Secure" : "";
|
|
507
|
+
res.setHeader("set-cookie", `nr_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/${secure}`);
|
|
436
508
|
}
|
|
437
509
|
function clearSessionCookie(res) {
|
|
438
510
|
res.setHeader("set-cookie", "nr_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0");
|
|
439
511
|
}
|
|
440
|
-
function
|
|
512
|
+
function isHttpsRequest(req) {
|
|
513
|
+
return Boolean(req.socket.encrypted) ||
|
|
514
|
+
String(req.headers["x-forwarded-proto"] ?? "").split(",")[0]?.trim().toLowerCase() === "https";
|
|
515
|
+
}
|
|
516
|
+
function currentUserDto(authUser, req, sessionToken) {
|
|
517
|
+
const token = sessionToken ?? (req ? parseCookies(req.headers.cookie ?? "").nr_session : undefined);
|
|
441
518
|
return {
|
|
442
519
|
user: publicUser(authUser.user),
|
|
443
520
|
groups: authUser.groups,
|
|
444
521
|
permissions: authUser.permissions,
|
|
522
|
+
csrfToken: token ? csrfTokenForSession(token) : undefined,
|
|
445
523
|
};
|
|
446
524
|
}
|
|
525
|
+
function requiresCsrf(req, url) {
|
|
526
|
+
return requiresWebCsrf(req.method, url.pathname);
|
|
527
|
+
}
|
|
528
|
+
function verifyCsrf(req) {
|
|
529
|
+
const sessionToken = parseCookies(req.headers.cookie ?? "").nr_session;
|
|
530
|
+
const supplied = headerValue(req, "x-nordrelay-csrf");
|
|
531
|
+
if (!sessionToken || !supplied) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
return safeEqualString(supplied, csrfTokenForSession(sessionToken));
|
|
535
|
+
}
|
|
536
|
+
function csrfTokenForSession(sessionToken) {
|
|
537
|
+
return createHmac("sha256", csrfSecret).update(sessionToken).digest("base64url");
|
|
538
|
+
}
|
|
539
|
+
function headerValue(req, name) {
|
|
540
|
+
const value = req.headers[name.toLowerCase()];
|
|
541
|
+
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
|
542
|
+
}
|
|
543
|
+
function safeEqualString(left, right) {
|
|
544
|
+
const leftBuffer = Buffer.from(left);
|
|
545
|
+
const rightBuffer = Buffer.from(right);
|
|
546
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
547
|
+
}
|
|
447
548
|
function audit(event) {
|
|
448
549
|
try {
|
|
449
550
|
auditLog.append(event);
|
|
@@ -612,25 +713,6 @@ async function assertCurrentSessionScope(authUser) {
|
|
|
612
713
|
function objectValue(value) {
|
|
613
714
|
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
614
715
|
}
|
|
615
|
-
function consumeRateLimit(buckets, key, limit, windowMs, blockMs) {
|
|
616
|
-
const now = Date.now();
|
|
617
|
-
const existing = buckets.get(key);
|
|
618
|
-
if (existing?.blockedUntil && existing.blockedUntil > now) {
|
|
619
|
-
return { limited: true, retryAfterMs: existing.blockedUntil - now };
|
|
620
|
-
}
|
|
621
|
-
const bucket = !existing || existing.resetAt <= now ? { count: 0, resetAt: now + windowMs } : existing;
|
|
622
|
-
bucket.count += 1;
|
|
623
|
-
if (bucket.count > limit) {
|
|
624
|
-
bucket.blockedUntil = now + blockMs;
|
|
625
|
-
buckets.set(key, bucket);
|
|
626
|
-
return { limited: true, retryAfterMs: blockMs };
|
|
627
|
-
}
|
|
628
|
-
buckets.set(key, bucket);
|
|
629
|
-
return { limited: false };
|
|
630
|
-
}
|
|
631
|
-
function resetRateLimit(buckets, key) {
|
|
632
|
-
buckets.delete(key);
|
|
633
|
-
}
|
|
634
716
|
function parseAgentId(value) {
|
|
635
717
|
if (!value) {
|
|
636
718
|
return undefined;
|
|
@@ -742,6 +824,8 @@ function activeSettingsValues(current) {
|
|
|
742
824
|
TELEGRAM_EDIT_MIN_INTERVAL_MS: String(current.telegramEditMinIntervalMs),
|
|
743
825
|
NORDRELAY_CLI_MIRROR_MODE: current.mirrorMode,
|
|
744
826
|
NORDRELAY_CLI_MIRROR_MIN_UPDATE_MS: String(current.mirrorMinUpdateMs),
|
|
827
|
+
NORDRELAY_WEB_CLI_MIRROR_MODE: current.webMirrorMode === current.mirrorMode ? "" : current.webMirrorMode,
|
|
828
|
+
NORDRELAY_WEB_CLI_MIRROR_MIN_UPDATE_MS: current.webMirrorMinUpdateMs === current.mirrorMinUpdateMs ? "" : String(current.webMirrorMinUpdateMs),
|
|
745
829
|
NORDRELAY_NOTIFY_MODE: current.notifyMode,
|
|
746
830
|
NORDRELAY_QUIET_HOURS: quietValue(current.quietHours),
|
|
747
831
|
NORDRELAY_AUTO_SEND_ARTIFACTS: boolValue(current.autoSendArtifacts),
|