@nordbyte/nordrelay 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -378,6 +378,15 @@ nordrelay peer invite --name workstation --scopes inspect,sessions.read,sessions
378
378
  nordrelay peer add https://workstation.example:31979 --code one-time-code
379
379
  ```
380
380
 
381
+ If the controlling host should also be reachable by the remote peer, enable its peer server and set
382
+ `NORDRELAY_PEER_PUBLIC_URL` before running `peer add`, or pass it explicitly:
383
+
384
+ ```bash
385
+ nordrelay peer add https://workstation.example:31979 --code one-time-code --public-url https://controller.example:31979
386
+ ```
387
+
388
+ NordRelay validates the advertised public URL against the peer identity endpoint during pairing and stores the current TLS certificate fingerprint for later reachability probes.
389
+
381
390
  7. Confirm the connection:
382
391
 
383
392
  ```bash
@@ -35,6 +35,42 @@ export async function checkPeerEndpoint(url, options = {}) {
35
35
  };
36
36
  }
37
37
  }
38
+ export async function checkPeerIdentityEndpoint(url, options = {}) {
39
+ const target = joinPeerUrl(url, "/peer/identity");
40
+ const startedAt = Date.now();
41
+ try {
42
+ const result = await requestJson({
43
+ url: target,
44
+ method: "GET",
45
+ expectedTlsFingerprint: options.expectedTlsFingerprint,
46
+ allowSelfSigned: true,
47
+ timeoutMs: options.timeoutMs ?? 4_000,
48
+ });
49
+ const identity = parsePeerIdentity(result.data?.identity);
50
+ const protocolVersion = result.data?.protocolVersion;
51
+ return {
52
+ ok: Boolean(identity) && protocolVersion === PEER_PROTOCOL_VERSION,
53
+ status: "reachable",
54
+ url: target,
55
+ latencyMs: Date.now() - startedAt,
56
+ statusCode: result.statusCode,
57
+ tlsFingerprint: result.tlsFingerprint,
58
+ identity,
59
+ detail: identity && protocolVersion === PEER_PROTOCOL_VERSION
60
+ ? "Peer identity endpoint is reachable."
61
+ : "Endpoint responded, but did not return the expected peer identity payload.",
62
+ };
63
+ }
64
+ catch (error) {
65
+ return {
66
+ ok: false,
67
+ status: "unreachable",
68
+ url: target,
69
+ latencyMs: Date.now() - startedAt,
70
+ detail: error instanceof Error ? error.message : String(error),
71
+ };
72
+ }
73
+ }
38
74
  export async function pairPeer(options, identity, store = new PeerStore()) {
39
75
  const timestamp = new Date().toISOString();
40
76
  const payload = createPairingSignaturePayload(identity.public.nodeId, timestamp, options.code);
@@ -65,7 +101,7 @@ export async function pairPeer(options, identity, store = new PeerStore()) {
65
101
  nodeId: result.data.identity.nodeId,
66
102
  publicKey: result.data.identity.publicKey,
67
103
  fingerprint: result.data.identity.fingerprint,
68
- tlsFingerprint: result.tlsFingerprint,
104
+ tlsFingerprint: result.tlsFingerprint ?? null,
69
105
  secret: result.data.secret,
70
106
  enabled: true,
71
107
  direction: "outbound",
@@ -286,3 +322,23 @@ function normalizePeerUrl(value) {
286
322
  function normalizeFingerprint(value) {
287
323
  return value?.trim().toLowerCase();
288
324
  }
325
+ function parsePeerIdentity(value) {
326
+ if (!value || typeof value !== "object") {
327
+ return undefined;
328
+ }
329
+ const record = value;
330
+ if (typeof record.nodeId !== "string" ||
331
+ typeof record.name !== "string" ||
332
+ typeof record.publicKey !== "string" ||
333
+ typeof record.fingerprint !== "string" ||
334
+ typeof record.createdAt !== "string") {
335
+ return undefined;
336
+ }
337
+ return {
338
+ nodeId: record.nodeId,
339
+ name: record.name,
340
+ publicKey: record.publicKey,
341
+ fingerprint: record.fingerprint,
342
+ createdAt: record.createdAt,
343
+ };
344
+ }
@@ -4,6 +4,7 @@ import { URL } from "node:url";
4
4
  import { friendlyErrorText } from "./error-messages.js";
5
5
  import { createPairingSignaturePayload, createSharedSecret, ensurePeerTlsFiles, fingerprintForPublicKey, loadOrCreatePeerIdentity, verifyPeerPayload, } from "./peer-identity.js";
6
6
  import { header, PeerNonceCache, verifyPeerRequest } from "./peer-auth.js";
7
+ import { checkPeerIdentityEndpoint } from "./peer-client.js";
7
8
  import { peerRuntimeContextKey } from "./peer-context.js";
8
9
  import { PeerStore } from "./peer-store.js";
9
10
  import { PeerRuntimeService, peerError } from "./peer-runtime-service.js";
@@ -78,7 +79,7 @@ export async function startPeerServer(options) {
78
79
  if (req.method === "POST" && url.pathname === "/peer/pair") {
79
80
  const bodyText = await readBody(req, 128 * 1024);
80
81
  const body = parseJson(bodyText);
81
- const response = handlePair(body);
82
+ const response = await handlePair(body);
82
83
  sendJson(res, 201, response);
83
84
  return;
84
85
  }
@@ -123,7 +124,7 @@ export async function startPeerServer(options) {
123
124
  sendJson(res, status, { error: friendlyErrorText(error) });
124
125
  }
125
126
  }
126
- function handlePair(body) {
127
+ async function handlePair(body) {
127
128
  if (!body?.identity?.nodeId || !body.identity.publicKey || !body.code || !body.signature || !body.timestamp) {
128
129
  throw new Error("Invalid peer pairing request.");
129
130
  }
@@ -140,17 +141,20 @@ export async function startPeerServer(options) {
140
141
  if (!verifyPeerPayload(body.identity.publicKey, signaturePayload, body.signature)) {
141
142
  throw new Error("Invalid peer pairing signature.");
142
143
  }
144
+ const publicUrl = body.publicUrl?.trim() || undefined;
145
+ const publicUrlTlsFingerprint = publicUrl ? await verifyPairingPublicUrl(publicUrl, body.identity) : undefined;
143
146
  const invitation = store.consumeInvitation(body.code, body.identity.nodeId);
144
147
  const secret = createSharedSecret();
145
148
  const peer = store.upsertPeer({
146
149
  name: body.name?.trim() || body.identity.name || invitation.name,
147
- url: body.publicUrl,
150
+ url: publicUrl,
148
151
  nodeId: body.identity.nodeId,
149
152
  publicKey: body.identity.publicKey,
150
153
  fingerprint: body.identity.fingerprint,
154
+ tlsFingerprint: publicUrl ? publicUrlTlsFingerprint ?? null : undefined,
151
155
  secret,
152
156
  enabled: true,
153
- direction: body.publicUrl ? "bidirectional" : "inbound",
157
+ direction: publicUrl ? "bidirectional" : "inbound",
154
158
  scopes: invitation.scopes,
155
159
  allowedAgents: invitation.allowedAgents,
156
160
  allowedWorkspaceRoots: invitation.allowedWorkspaceRoots,
@@ -167,6 +171,18 @@ export async function startPeerServer(options) {
167
171
  workspaceAliases: peer.workspaceAliases,
168
172
  };
169
173
  }
174
+ async function verifyPairingPublicUrl(publicUrl, expectedIdentity) {
175
+ const probe = await checkPeerIdentityEndpoint(publicUrl, { timeoutMs: 4_000 });
176
+ if (!probe.ok || !probe.identity) {
177
+ throw new Error(`Peer public URL is not reachable or does not expose a valid NordRelay identity: ${probe.detail}`);
178
+ }
179
+ if (probe.identity.nodeId !== expectedIdentity.nodeId ||
180
+ probe.identity.publicKey !== expectedIdentity.publicKey ||
181
+ probe.identity.fingerprint !== expectedIdentity.fingerprint) {
182
+ throw new Error("Peer public URL identity does not match the pairing identity.");
183
+ }
184
+ return probe.tlsFingerprint;
185
+ }
170
186
  function authenticate(req, method, pathname, body) {
171
187
  const peerId = header(req, "x-nordrelay-peer-id");
172
188
  const peer = peerId ? store.get(peerId) : null;
@@ -91,7 +91,9 @@ export class PeerStore {
91
91
  existing.url = input.url ?? existing.url;
92
92
  existing.publicKey = input.publicKey;
93
93
  existing.fingerprint = input.fingerprint;
94
- existing.tlsFingerprint = input.tlsFingerprint ?? existing.tlsFingerprint;
94
+ if (input.tlsFingerprint !== undefined) {
95
+ existing.tlsFingerprint = input.tlsFingerprint || undefined;
96
+ }
95
97
  existing.secret = input.secret;
96
98
  existing.enabled = input.enabled ?? existing.enabled;
97
99
  existing.direction = mergeDirection(existing.direction, input.direction ?? existing.direction);
@@ -111,7 +113,7 @@ export class PeerStore {
111
113
  nodeId: input.nodeId,
112
114
  publicKey: input.publicKey,
113
115
  fingerprint: input.fingerprint,
114
- tlsFingerprint: input.tlsFingerprint,
116
+ tlsFingerprint: input.tlsFingerprint || undefined,
115
117
  secret: input.secret,
116
118
  enabled: input.enabled ?? true,
117
119
  direction: input.direction ?? "outbound",
@@ -2166,7 +2166,8 @@
2166
2166
  applyPermissions();
2167
2167
  }
2168
2168
  function openPeerAddDialog() {
2169
- adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional"></label>', async () => {
2169
+ const publicUrl = state.peers?.enabled ? state.peers?.listenUrl || "" : "";
2170
+ adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional" value="' + attr(publicUrl) + '"></label>', async () => {
2170
2171
  const r = await api("/api/peers/pair", { method: "POST", body: JSON.stringify({ url: val("dlgPeerAddUrl"), code: val("dlgPeerAddCode"), name: val("dlgPeerAddName") || void 0, publicUrl: val("dlgPeerAddPublicUrl") || void 0 }), local: true });
2171
2172
  toast("Added peer " + (r.peer?.name || ""));
2172
2173
  await loadPeers();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nordbyte/nordrelay",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Remote control plane for coding agents across messaging channels.",
5
5
  "type": "module",
6
6
  "author": "Ricardo",
@@ -915,16 +915,19 @@ async function commandPeer(options) {
915
915
  if (flags.subcommand === "add") {
916
916
  const url = flags.url || await ask(null, "Peer URL", "");
917
917
  const code = flags.code || await ask(null, "Pairing code", "");
918
+ const configuredPublicUrl = process.env.NORDRELAY_PEER_ENABLED === "true" ? process.env.NORDRELAY_PEER_PUBLIC_URL : undefined;
919
+ const publicUrl = flags.publicUrl || configuredPublicUrl;
918
920
  const result = await clientMod.pairPeer({
919
921
  url,
920
922
  code,
921
923
  name: flags.name,
922
- publicUrl: flags.publicUrl,
924
+ publicUrl,
923
925
  }, identity, store);
924
926
  console.log(`Added peer ${result.peer.name} (${result.peer.id}).`);
925
927
  console.log(`Node: ${result.peer.nodeId}`);
926
928
  console.log(`Fingerprint: ${result.peer.fingerprint}`);
927
929
  if (result.tlsFingerprint) console.log(`TLS fingerprint: ${result.tlsFingerprint}`);
930
+ if (publicUrl) console.log(`Shared public URL: ${publicUrl}`);
928
931
  return;
929
932
  }
930
933