@reticulum/dacar 1.1.0 → 1.1.2
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/package.json +1 -1
- package/src/cli/dacar.js +200 -54
- package/src/cli/rns_boot.js +131 -0
- package/src/cli/session.js +140 -3
- package/src/cli/store.js +45 -1
- package/src/crdt.js +9 -3
package/package.json
CHANGED
package/src/cli/dacar.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* dacar check <grantee> <relation> <object>
|
|
19
19
|
* dacar grants
|
|
20
20
|
* dacar revoke <grantee> <relation> <object> [--publish]
|
|
21
|
+
* dacar publish <file> [<file>...] | --all (work doc #8)
|
|
21
22
|
* dacar identity remember|forget|list ...
|
|
22
23
|
*
|
|
23
24
|
* Online flags: --node <hash>, --topic <topic>, --interface shared|auto|tcp,
|
|
@@ -28,17 +29,14 @@ import { parseArgs } from "node:util";
|
|
|
28
29
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
29
30
|
import { homedir } from "node:os";
|
|
30
31
|
import { join } from "node:path";
|
|
32
|
+
import { pathToFileURL } from "node:url";
|
|
31
33
|
import process from "node:process";
|
|
32
34
|
|
|
33
|
-
import { Identity,
|
|
35
|
+
import { Identity, toHex } from "@reticulum/core";
|
|
34
36
|
import { MemoryStorageAdapter } from "@reticulum/core";
|
|
35
|
-
import {
|
|
36
|
-
AutoInterface,
|
|
37
|
-
FileStorageAdapter,
|
|
38
|
-
LocalClientInterface,
|
|
39
|
-
TCPClientInterface,
|
|
40
|
-
} from "@reticulum/node";
|
|
37
|
+
import { FileStorageAdapter } from "@reticulum/node";
|
|
41
38
|
import { RFedClient } from "@reticulum/core/src/rfed/client.js";
|
|
39
|
+
import { bootRns } from "./rns_boot.js";
|
|
42
40
|
|
|
43
41
|
import { Action, Operation, Tuple, Engine } from "../index.js";
|
|
44
42
|
import { DeltaReceiver } from "../delta.js";
|
|
@@ -48,7 +46,7 @@ import { NamespaceHasher, DEFAULT_SALT, SALT_SIZE, HASH_SIZE } from "../namespac
|
|
|
48
46
|
import { Keyring, IssuerKeyset } from "../verifier.js";
|
|
49
47
|
|
|
50
48
|
import { DacarStore, SELF_ALIAS, AliasRegistry } from "./store.js";
|
|
51
|
-
import { announceIdentity, runPublish, runSync, registerAnnounceHandler } from "./session.js";
|
|
49
|
+
import { announceIdentity, discoverRfedNode, ensureNodeIdentity, runPublish, runSync, registerAnnounceHandler } from "./session.js";
|
|
52
50
|
|
|
53
51
|
const SHORT_HASH = 7;
|
|
54
52
|
|
|
@@ -131,30 +129,18 @@ async function resolveRnsConfigDir(args) {
|
|
|
131
129
|
return dir;
|
|
132
130
|
}
|
|
133
131
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const rns = new Reticulum({ storageAdapter: new FileStorageAdapter(configDir) });
|
|
140
|
-
if (iface === "auto") {
|
|
141
|
-
rns.addInterface(new AutoInterface({}));
|
|
142
|
-
} else if (iface === "tcp") {
|
|
143
|
-
const host = process.env.RNS_HOST || "127.0.0.1";
|
|
144
|
-
const port = parseInt(process.env.RNS_PORT || "42424", 10);
|
|
145
|
-
rns.addInterface(new TCPClientInterface({ host, port }));
|
|
146
|
-
} else {
|
|
147
|
-
// shared (default): attach to a running rnsd, else no-op (standalone).
|
|
148
|
-
rns.addInterface(new LocalClientInterface({}));
|
|
149
|
-
}
|
|
150
|
-
return rns;
|
|
151
|
-
}
|
|
132
|
+
// `bootRns` lives in `./rns_boot.js` (Node-only): it constructs, **connects**,
|
|
133
|
+
// and default-attaches the chosen mesh interface, and optionally raises the
|
|
134
|
+
// Reticulum log threshold + logs each announce for `--verbose`. See
|
|
135
|
+
// `src/cli/rns_boot.js` for why `connect()` + `isDefault=true` are
|
|
136
|
+
// non-optional (the `--discover` silent-timeout symptom).
|
|
152
137
|
|
|
153
|
-
async function resolveRfedNode(args, store, aliases) {
|
|
138
|
+
async function resolveRfedNode(args, store, aliases, rns) {
|
|
154
139
|
if (args.node) return resolveIdentityHash(args.node, aliases);
|
|
155
140
|
const raw = await store.loadConfig();
|
|
156
141
|
if (raw.rfedNode) return raw.rfedNode;
|
|
157
|
-
|
|
142
|
+
if (args.discover && rns) return discoverRfedNode({ rns, timeout: 30000 });
|
|
143
|
+
throw new CliError("no rfed node configured (use --node <hash>, --discover, or set [rfed] node in config)");
|
|
158
144
|
}
|
|
159
145
|
|
|
160
146
|
async function resolveTopic(args, store) {
|
|
@@ -235,28 +221,47 @@ async function _issue(args, action) {
|
|
|
235
221
|
|
|
236
222
|
// Record plaintext ledger.
|
|
237
223
|
const ledger = await store.loadLedger();
|
|
238
|
-
ledger.set(
|
|
224
|
+
ledger.set(tuple.key, { object: args.object, relation: args.relation, wildcard: args.object.endsWith("*") && args.object !== "*", firstSeen: Number(hlc >> 16n) });
|
|
239
225
|
await store.saveLedger(ledger);
|
|
240
226
|
|
|
241
|
-
out(payload
|
|
227
|
+
out(toHex(payload));
|
|
242
228
|
err(`✔ ${action === Action.GRANT ? "granted" : "revoked"} ${shortHash(grantee, args.fullHashes)} ${args.relation} on ${args.object}`);
|
|
243
229
|
err(` hlc : 0x${hlc.toString(16)}`);
|
|
244
230
|
err(` payload : hex on stdout (${payload.length} bytes)`);
|
|
245
231
|
|
|
246
232
|
if (args.publish) {
|
|
247
233
|
await publishDelta(args, store, identity, payload);
|
|
234
|
+
} else {
|
|
235
|
+
// Outbox (work doc #8): queue locally-issued deltas for `publish --all`.
|
|
236
|
+
// `--publish` sends immediately and never enqueues. (JS `grant` always
|
|
237
|
+
// applies locally — there is no `--no-apply` — so every non-publish grant
|
|
238
|
+
// is a candidate for later batch publish.)
|
|
239
|
+
const outbox = await store.loadOutbox();
|
|
240
|
+
outbox.push(payload);
|
|
241
|
+
await store.saveOutbox(outbox);
|
|
242
|
+
err(" (queued in outbox: `dacar publish --all` to flush)");
|
|
248
243
|
}
|
|
249
244
|
return 0;
|
|
250
245
|
}
|
|
251
246
|
|
|
252
247
|
async function publishDelta(args, store, identity, payload) {
|
|
253
248
|
const aliases = await store.loadAliases();
|
|
254
|
-
const nodeHash = await resolveRfedNode(args, store, aliases);
|
|
255
249
|
const topic = await resolveTopic(args, store);
|
|
256
|
-
|
|
257
250
|
const configDir = await resolveRnsConfigDir(args);
|
|
258
|
-
const rns = await bootRns(configDir, args.interface || "shared"
|
|
259
|
-
|
|
251
|
+
const rns = await bootRns(configDir, args.interface || "shared", {
|
|
252
|
+
verbose: !!args.verbose,
|
|
253
|
+
});
|
|
254
|
+
// RNS must be booted before resolveRfedNode: --discover listens for peer
|
|
255
|
+
// announces on the live transport (mirrors Python's _publish_delta).
|
|
256
|
+
const nodeHash = await resolveRfedNode(args, store, aliases, rns);
|
|
257
|
+
await announceIdentity(identity, rns);
|
|
258
|
+
// Proactively fetch the rfed node's identity: when --node is given (or
|
|
259
|
+
// --discover derived it), the destination's announce may not yet be in
|
|
260
|
+
// the recall store. Send a path? request and wait for the announce rather
|
|
261
|
+
// than failing with "wait for its announce" (work doc #6).
|
|
262
|
+
await ensureNodeIdentity(rns, nodeHash, {
|
|
263
|
+
onRequest: () => err(" requesting rfed node identity…"),
|
|
264
|
+
});
|
|
260
265
|
|
|
261
266
|
// Durable issuer cache (doc #5): seed from observed dacar.node announces.
|
|
262
267
|
const keyring = await store.loadKeyring();
|
|
@@ -276,18 +281,27 @@ async function cmdSync(args) {
|
|
|
276
281
|
const identity = await store.loadIdentity();
|
|
277
282
|
if (!identity) throw new CliError("no signing identity (run `dacar init`)");
|
|
278
283
|
|
|
279
|
-
|
|
280
|
-
const topic = await resolveTopic(args, store);
|
|
281
|
-
|
|
284
|
+
// RNS must be booted before discover if we're autodiscovering
|
|
282
285
|
const configDir = await resolveRnsConfigDir(args);
|
|
283
|
-
const rns = await bootRns(configDir, args.interface || "shared"
|
|
284
|
-
|
|
286
|
+
const rns = await bootRns(configDir, args.interface || "shared", {
|
|
287
|
+
verbose: !!args.verbose,
|
|
288
|
+
});
|
|
289
|
+
await announceIdentity(identity, rns);
|
|
285
290
|
|
|
286
291
|
// Durable issuer cache (doc #5): load persisted keyring + announce handler.
|
|
287
292
|
const keyring = await store.loadKeyring();
|
|
288
293
|
keyring.registerSingle(identity.identityHash, await identity.getPublicKey());
|
|
289
294
|
await registerAnnounceHandler({ rns, keyring, onSave: (kr) => store.saveKeyring(kr) });
|
|
290
295
|
|
|
296
|
+
const nodeHash = await resolveRfedNode(args, store, aliases, rns);
|
|
297
|
+
// Proactively fetch the rfed node's identity: when --node is given (or
|
|
298
|
+
// --discover derived it), the destination's announce may not yet be in
|
|
299
|
+
// the recall store. Send a path? request and wait for the announce rather
|
|
300
|
+
// than failing with "wait for its announce" (work doc #6).
|
|
301
|
+
await ensureNodeIdentity(rns, nodeHash, {
|
|
302
|
+
onRequest: () => err(" requesting rfed node identity…"),
|
|
303
|
+
});
|
|
304
|
+
const topic = await resolveTopic(args, store);
|
|
291
305
|
const state = await store.loadState(config);
|
|
292
306
|
const resolver = new RnsIdentityResolver(keyring);
|
|
293
307
|
const rx = new DeltaReceiver(state, resolver);
|
|
@@ -333,6 +347,107 @@ async function cmdApply(args) {
|
|
|
333
347
|
return 1;
|
|
334
348
|
}
|
|
335
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Read a payload file (or stdin) and auto-detect hex (mirrors Python's
|
|
352
|
+
* `_read_payload_input`): an all-hex, even-length ASCII blob decodes to bytes.
|
|
353
|
+
* `--binary` forces raw bytes.
|
|
354
|
+
* @param {string} path
|
|
355
|
+
* @param {boolean} forceBinary
|
|
356
|
+
* @returns {Promise<Uint8Array>}
|
|
357
|
+
*/
|
|
358
|
+
async function readPayloadInput(path, forceBinary) {
|
|
359
|
+
const data = path === "-" ? new Uint8Array(await readStdin()) : await readFile(path);
|
|
360
|
+
return coercePayload(data, forceBinary);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Auto-detect a hex payload: if *all* bytes are ASCII hex characters (after
|
|
365
|
+
* trimming surrounding whitespace) and the length is even, decode to bytes;
|
|
366
|
+
* otherwise return the raw bytes unchanged.
|
|
367
|
+
* @param {Uint8Array | Buffer} data
|
|
368
|
+
* @param {boolean} forceBinary
|
|
369
|
+
* @returns {Uint8Array}
|
|
370
|
+
*/
|
|
371
|
+
function coercePayload(data, forceBinary) {
|
|
372
|
+
const bytes = new Uint8Array(data);
|
|
373
|
+
if (forceBinary || !bytes.length) return bytes;
|
|
374
|
+
let s;
|
|
375
|
+
try {
|
|
376
|
+
s = Buffer.from(bytes).toString("ascii");
|
|
377
|
+
} catch {
|
|
378
|
+
return bytes; // not ASCII -> raw bytes
|
|
379
|
+
}
|
|
380
|
+
const trimmed = s.trim();
|
|
381
|
+
if (!trimmed || trimmed.length % 2 !== 0) return bytes;
|
|
382
|
+
if (!/^[0-9a-fA-F]+$/.test(trimmed)) return bytes;
|
|
383
|
+
return hexToBytes(trimmed);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export { coercePayload };
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* `dacar publish` — push signed delta(s) to the rfed channel (§11.1, doc #8).
|
|
390
|
+
*
|
|
391
|
+
* Two modes:
|
|
392
|
+
* - `dacar publish <file> [<file>...]` — publish previously-signed delta
|
|
393
|
+
* payload(s) (exact bytes, no re-sign; multiple files → one batch).
|
|
394
|
+
* - `dacar publish --all` — pack + publish the outbox of locally-issued
|
|
395
|
+
* deltas, then clear it. A no-op (exit 0) on an empty outbox.
|
|
396
|
+
*
|
|
397
|
+
* Reuses the `grant --publish` machinery (`publishDelta`: boot RNS, announce,
|
|
398
|
+
* subscribe, publish).
|
|
399
|
+
*/
|
|
400
|
+
async function cmdPublish(args) {
|
|
401
|
+
const store = await openStore(args);
|
|
402
|
+
const identity = await store.loadIdentity();
|
|
403
|
+
if (!identity) throw new CliError("no signing identity (run `dacar init`)");
|
|
404
|
+
|
|
405
|
+
const useAll = !!args.all;
|
|
406
|
+
const files = args._positionals ?? [];
|
|
407
|
+
|
|
408
|
+
if (useAll && files.length) {
|
|
409
|
+
throw new CliError("publish: use either <file>... or --all, not both");
|
|
410
|
+
}
|
|
411
|
+
if (!useAll && !files.length) {
|
|
412
|
+
throw new CliError("publish: provide <file>... (one or more) or --all");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** @type {Uint8Array[]} */
|
|
416
|
+
let toPublish;
|
|
417
|
+
if (useAll) {
|
|
418
|
+
toPublish = await store.loadOutbox();
|
|
419
|
+
if (!toPublish.length) {
|
|
420
|
+
err("outbox empty (nothing to publish)");
|
|
421
|
+
return 0;
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
toPublish = [];
|
|
425
|
+
for (const path of files) {
|
|
426
|
+
const data = await readPayloadInput(path, !!args.binary);
|
|
427
|
+
if (!data.length) throw new CliError(`empty payload: ${path}`);
|
|
428
|
+
toPublish.push(data);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Pack: a single delta as raw bytes, multiple as a batch payload.
|
|
433
|
+
const batch = toPublish.length === 1
|
|
434
|
+
? toPublish[0]
|
|
435
|
+
: DeltaReceiver.packPayloads(toPublish);
|
|
436
|
+
|
|
437
|
+
const label = useAll ? "outbox" : `${toPublish.length} file(s)`;
|
|
438
|
+
const kind = toPublish.length > 1 ? "a batch" : "a single delta";
|
|
439
|
+
err(` publishing ${toPublish.length} delta(s) (${label}) as ${kind}`);
|
|
440
|
+
|
|
441
|
+
// Reuse the grant --publish machinery (boot RNS, announce, subscribe, publish).
|
|
442
|
+
await publishDelta(args, store, identity, batch);
|
|
443
|
+
|
|
444
|
+
if (useAll) {
|
|
445
|
+
await store.saveOutbox([]);
|
|
446
|
+
err(" outbox cleared");
|
|
447
|
+
}
|
|
448
|
+
return 0;
|
|
449
|
+
}
|
|
450
|
+
|
|
336
451
|
async function readStdin() {
|
|
337
452
|
const chunks = [];
|
|
338
453
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
@@ -358,7 +473,9 @@ async function cmdIdentityRemember(args) {
|
|
|
358
473
|
} else {
|
|
359
474
|
// Boot RNS and try to recall.
|
|
360
475
|
const configDir = await resolveRnsConfigDir(args);
|
|
361
|
-
const rns = await bootRns(configDir, args.interface || "shared"
|
|
476
|
+
const rns = await bootRns(configDir, args.interface || "shared", {
|
|
477
|
+
verbose: !!args.verbose,
|
|
478
|
+
});
|
|
362
479
|
const { Destination } = await import("@reticulum/core");
|
|
363
480
|
const recalled = await Destination.recall(issuerHash, true);
|
|
364
481
|
if (!recalled) {
|
|
@@ -442,7 +559,7 @@ async function cmdGrants(args) {
|
|
|
442
559
|
err(`${label} (${rows.length})`);
|
|
443
560
|
for (const { entry, active } of rows) {
|
|
444
561
|
const t = entry.tuple;
|
|
445
|
-
const row = ledger.get(
|
|
562
|
+
const row = ledger.get(t.key);
|
|
446
563
|
const rel = row?.relation || `[${shortHash(t.relationHash, args.fullHashes)}]`;
|
|
447
564
|
const obj = row?.object || "[hash]";
|
|
448
565
|
err(
|
|
@@ -466,19 +583,25 @@ const SUBCOMMANDS = {
|
|
|
466
583
|
},
|
|
467
584
|
grant: {
|
|
468
585
|
run: cmdGrant,
|
|
469
|
-
opts: { publish: "boolean", node: "string", topic: "string", "rns-dir": "string", interface: "string" },
|
|
586
|
+
opts: { publish: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
|
|
470
587
|
positional: ["grantee", "relation", "object"],
|
|
471
588
|
online: true,
|
|
472
589
|
},
|
|
473
590
|
revoke: {
|
|
474
591
|
run: cmdRevoke,
|
|
475
|
-
opts: { publish: "boolean", node: "string", topic: "string", "rns-dir": "string", interface: "string" },
|
|
592
|
+
opts: { publish: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
|
|
476
593
|
positional: ["grantee", "relation", "object"],
|
|
477
594
|
online: true,
|
|
478
595
|
},
|
|
479
596
|
sync: {
|
|
480
597
|
run: cmdSync,
|
|
481
|
-
opts: { node: "string", topic: "string", "rns-dir": "string", interface: "string" },
|
|
598
|
+
opts: { node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
|
|
599
|
+
online: true,
|
|
600
|
+
},
|
|
601
|
+
publish: {
|
|
602
|
+
run: cmdPublish,
|
|
603
|
+
opts: { all: "boolean", binary: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
|
|
604
|
+
// variable file list (0..N) accessed via args._positionals
|
|
482
605
|
online: true,
|
|
483
606
|
},
|
|
484
607
|
apply: { run: cmdApply, opts: { binary: "boolean" }, positional: ["payload"], online: false },
|
|
@@ -503,17 +626,22 @@ function buildOptions(spec) {
|
|
|
503
626
|
for (const [k, t] of Object.entries(spec.opts || {})) {
|
|
504
627
|
opts[k] = { type: t };
|
|
505
628
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
629
|
+
// --verbose / -v is global: accepted by every (sub)command so it never
|
|
630
|
+
// errors out, and threaded into bootRns to raise the Reticulum log
|
|
631
|
+
// threshold + log interface/announce diagnostics.
|
|
632
|
+
opts.verbose = { type: "boolean", short: "v" };
|
|
633
|
+
// --store / --identity / --full-hashes are global on every (sub)command
|
|
634
|
+
// (they were previously gated on `positional`, which silently dropped them
|
|
635
|
+
// for commands with no positionals — e.g. `sync`, `config show`, `grants`).
|
|
636
|
+
opts.store = { type: "string" };
|
|
637
|
+
opts.identity = { type: "string" };
|
|
638
|
+
opts["full-hashes"] = { type: "boolean" };
|
|
511
639
|
return opts;
|
|
512
640
|
}
|
|
513
641
|
|
|
514
642
|
async function main() {
|
|
515
643
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
516
|
-
err(`usage: dacar <command> [options]\n\ncommands: ${Object.keys(SUBCOMMANDS).join(", ")}\n\nGlobal options: --store <path>, --identity <hex|path>, --full-hashes`);
|
|
644
|
+
err(`usage: dacar <command> [options]\n\ncommands: ${Object.keys(SUBCOMMANDS).join(", ")}\n\nGlobal options: --store <path>, --identity <hex|path>, --full-hashes, -v/--verbose`);
|
|
517
645
|
return 0;
|
|
518
646
|
}
|
|
519
647
|
const argv = process.argv.slice(2);
|
|
@@ -559,7 +687,25 @@ async function main() {
|
|
|
559
687
|
}
|
|
560
688
|
}
|
|
561
689
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
690
|
+
// Only auto-run when invoked as the entry script (Node/Bun via `pathToFileURL`,
|
|
691
|
+
// Deno via `import.meta.main`), so the module can be imported in tests without
|
|
692
|
+
// triggering the CLI dispatch (mirrors how `./cli/store` + `./cli/session`
|
|
693
|
+
// are unit-tested).
|
|
694
|
+
const isMain = (() => {
|
|
695
|
+
try {
|
|
696
|
+
if (import.meta.main === true) return true; // Deno
|
|
697
|
+
} catch { /* not Deno */ }
|
|
698
|
+
try {
|
|
699
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
700
|
+
return true; // Node / Bun
|
|
701
|
+
}
|
|
702
|
+
} catch { /* pathToFileURL unavailable */ }
|
|
703
|
+
return false;
|
|
704
|
+
})();
|
|
705
|
+
|
|
706
|
+
if (isMain) {
|
|
707
|
+
main().then((code) => process.exit(code ?? 0)).catch((e) => {
|
|
708
|
+
err("fatal: " + (e?.stack || e));
|
|
709
|
+
process.exit(1);
|
|
710
|
+
});
|
|
711
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-only RNS boot helpers (work doc #6).
|
|
3
|
+
*
|
|
4
|
+
* Constructs and **connects** a mesh interface, then attaches it to a booted
|
|
5
|
+
* `Reticulum` as the **default** interface — mirroring `@reticulum/node`'s
|
|
6
|
+
* `rfed` CLI `attachInterface`. This is non-optional: without `connect()` the
|
|
7
|
+
* interface's readable/writable streams are never set up and `_packetWriter`
|
|
8
|
+
* stays `null`, so `TransportCore.broadcast()` silently skips it
|
|
9
|
+
* (`if (!iface._packetWriter) continue`) and routed `sendPacket()` throws
|
|
10
|
+
* `No route to host`. No traffic flows in either direction — the
|
|
11
|
+
* `dacar sync --discover` "no rfed.node announce received within 30000ms"
|
|
12
|
+
* timeout symptom, even when an rfed node is announcing on the mesh.
|
|
13
|
+
*
|
|
14
|
+
* Node-only (imports `@reticulum/node` interfaces); kept out of the
|
|
15
|
+
* browser-portable `session.js`/`store.js` (see `test/cli-purity.test.js`).
|
|
16
|
+
* The Node-only CLI bin (`dacar.js`) composes this with the portable helpers.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Reticulum, setLogLevel, toHex } from "@reticulum/core";
|
|
20
|
+
import {
|
|
21
|
+
AutoInterface,
|
|
22
|
+
FileStorageAdapter,
|
|
23
|
+
LocalClientInterface,
|
|
24
|
+
TCPClientInterface,
|
|
25
|
+
} from "@reticulum/node";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Boot a `Reticulum` with a connected, default-attached mesh interface.
|
|
29
|
+
*
|
|
30
|
+
* `shared` (default) prefers a running rnsd via
|
|
31
|
+
* `LocalClientInterface.connectToSharedInstance()` (which internally
|
|
32
|
+
* connects); if no shared instance is reachable it falls back to
|
|
33
|
+
* `AutoInterface` so the node is still on the mesh — the same fallback the
|
|
34
|
+
* rfed CLI uses. `auto` and `tcp` connect their respective interfaces
|
|
35
|
+
* directly.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} configDir Reticulum storage directory (identity/paths).
|
|
38
|
+
* @param {"shared"|"auto"|"tcp"} iface Interface kind to attach.
|
|
39
|
+
* @param {Object} [opts]
|
|
40
|
+
* @param {boolean} [opts.verbose=false] Raise the Reticulum log threshold to
|
|
41
|
+
* `DEBUG` and log interface status + each validated announce the transport
|
|
42
|
+
* sees (dest/name-hash/hops), so a failing `--discover` shows whether any
|
|
43
|
+
* announces arrive at all and for which aspects.
|
|
44
|
+
* @param {() => Promise<import("@reticulum/node").LocalClientInterface | null>} [opts.sharedFactory]
|
|
45
|
+
* Injectable shared-instance connector (tests).
|
|
46
|
+
* @param {() => import("@reticulum/node").AutoInterface} [opts.autoFactory]
|
|
47
|
+
* Injectable AutoInterface factory (tests).
|
|
48
|
+
* @param {(host: string, port: number) => import("@reticulum/node").TCPClientInterface} [opts.tcpFactory]
|
|
49
|
+
* Injectable TCPClientInterface factory (tests).
|
|
50
|
+
* @returns {Promise<import("@reticulum/core").Reticulum>} A booted Reticulum
|
|
51
|
+
* with one connected default interface.
|
|
52
|
+
*/
|
|
53
|
+
export async function bootRns(configDir, iface, opts = {}) {
|
|
54
|
+
if (opts.verbose) setLogLevel("DEBUG");
|
|
55
|
+
const rns = new Reticulum({
|
|
56
|
+
storageAdapter: new FileStorageAdapter(configDir),
|
|
57
|
+
});
|
|
58
|
+
const label = await attachInterface(rns, iface, opts);
|
|
59
|
+
if (opts.verbose) {
|
|
60
|
+
const ifaces = [...rns.transport.interfaces];
|
|
61
|
+
const online = ifaces.filter((i) => i.online).length;
|
|
62
|
+
process.stderr.write(
|
|
63
|
+
` rns: interface=${label} attached=${ifaces.length} online=${online}\n`,
|
|
64
|
+
);
|
|
65
|
+
// Surface every announce the transport validates, so a failed --discover
|
|
66
|
+
// shows whether *any* announces are arriving (and for which aspects).
|
|
67
|
+
rns.transport.addEventListener("announce", (event) => {
|
|
68
|
+
const d = event.detail ?? {};
|
|
69
|
+
const hops = d.packet?.hops ?? "?";
|
|
70
|
+
process.stderr.write(
|
|
71
|
+
` announce: dest=${toHex(d.destinationHash ?? new Uint8Array())} ` +
|
|
72
|
+
`name=${toHex(d.nameHash ?? new Uint8Array())} hops=${hops}\n`,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return rns;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Construct, **connect**, and default-attach the requested interface kind.
|
|
81
|
+
*
|
|
82
|
+
* Connection is awaited before `addInterface(…, true)` so the interface's
|
|
83
|
+
* streams are live by the time the transport binds it (and so a connection
|
|
84
|
+
* failure surfaces immediately as a clear error rather than a 30s hang).
|
|
85
|
+
*
|
|
86
|
+
* @param {import("@reticulum/core").Reticulum} rns A booted Reticulum.
|
|
87
|
+
* @param {"shared"|"auto"|"tcp"} iface Interface kind.
|
|
88
|
+
* @param {Object} [opts] Factory overrides (see {@link bootRns}); `verbose`
|
|
89
|
+
* only affects the shared→auto fallback notice.
|
|
90
|
+
* @returns {Promise<string>} A human-readable label for the attached interface.
|
|
91
|
+
*/
|
|
92
|
+
export async function attachInterface(rns, iface, opts = {}) {
|
|
93
|
+
const makeAuto =
|
|
94
|
+
opts.autoFactory ?? (() => new AutoInterface({ name: "auto" }));
|
|
95
|
+
const makeTcp =
|
|
96
|
+
opts.tcpFactory ??
|
|
97
|
+
((host, port) => new TCPClientInterface({ host, port }));
|
|
98
|
+
const makeShared =
|
|
99
|
+
opts.sharedFactory ?? (() => LocalClientInterface.connectToSharedInstance());
|
|
100
|
+
|
|
101
|
+
if (iface === "auto") {
|
|
102
|
+
const auto = makeAuto();
|
|
103
|
+
await auto.connect();
|
|
104
|
+
rns.addInterface(auto, true);
|
|
105
|
+
return "AutoInterface";
|
|
106
|
+
}
|
|
107
|
+
if (iface === "tcp") {
|
|
108
|
+
const host = process.env.RNS_HOST || "127.0.0.1";
|
|
109
|
+
const port = parseInt(process.env.RNS_PORT || "42424", 10);
|
|
110
|
+
const tcp = makeTcp(host, port);
|
|
111
|
+
await tcp.connect();
|
|
112
|
+
rns.addInterface(tcp, true);
|
|
113
|
+
return `TCP ${host}:${port}`;
|
|
114
|
+
}
|
|
115
|
+
// shared (default): prefer a running rnsd; fall back to AutoInterface so the
|
|
116
|
+
// node is still on the mesh when no daemon is present (mirrors the rfed CLI).
|
|
117
|
+
const shared = await makeShared();
|
|
118
|
+
if (shared) {
|
|
119
|
+
rns.addInterface(shared, true);
|
|
120
|
+
return "shared rnsd instance";
|
|
121
|
+
}
|
|
122
|
+
if (opts.verbose) {
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
" rns: shared instance unavailable; falling back to AutoInterface\n",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const auto = makeAuto();
|
|
128
|
+
await auto.connect();
|
|
129
|
+
rns.addInterface(auto, true);
|
|
130
|
+
return "AutoInterface (shared instance unavailable)";
|
|
131
|
+
}
|
package/src/cli/session.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* core. It mirrors Python's `dacar/cli/rns.py` + `run_publish`/`run_sync`.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { Destination, Identity } from "@reticulum/core";
|
|
15
|
+
import { Destination, DestType, Identity, toHex } from "@reticulum/core";
|
|
16
16
|
import { APP_NAME } from "../naming.js";
|
|
17
17
|
import { RfedDeltaSync } from "../transport/rfedSync.js";
|
|
18
18
|
|
|
@@ -28,12 +28,79 @@ import { RfedDeltaSync } from "../transport/rfedSync.js";
|
|
|
28
28
|
* @param {import("@reticulum/core").Identity} identity
|
|
29
29
|
* @returns {Promise<Uint8Array>}
|
|
30
30
|
*/
|
|
31
|
-
export async function announceIdentity(identity) {
|
|
32
|
-
|
|
31
|
+
export async function announceIdentity(identity, rns = null) {
|
|
32
|
+
// `Destination.IN` is a static factory (`Destination.IN(name, type, identity,
|
|
33
|
+
// interfaceLayer)`) — NOT a Direction enum value (unlike Python RNS's
|
|
34
|
+
// `RNS.Destination.IN` constant). The destination must be bound to `rns` as
|
|
35
|
+
// its interface layer, or `announce()` throws "Destination not bound to an
|
|
36
|
+
// RNS instance." Mirrors `@reticulum/core`'s rfed/client.js `listen()`.
|
|
37
|
+
const dest = await Destination.IN(
|
|
38
|
+
`${APP_NAME}.node`,
|
|
39
|
+
DestType.SINGLE,
|
|
40
|
+
identity,
|
|
41
|
+
rns,
|
|
42
|
+
);
|
|
33
43
|
await dest.announce();
|
|
34
44
|
return dest.destinationHash;
|
|
35
45
|
}
|
|
36
46
|
|
|
47
|
+
/**
|
|
48
|
+
* How long {@link ensureNodeIdentity} waits for a path-response announce
|
|
49
|
+
* after sending a `path?` request before giving up, in milliseconds.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_NODE_DISCOVERY_TIMEOUT = 15_000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Recall a node's identity, proactively requesting its path if unknown.
|
|
55
|
+
*
|
|
56
|
+
* When `--node <hash>` (or `--discover`) resolves to an rfed destination whose
|
|
57
|
+
* announce isn't in the recall store yet, `RFedClient.subscribe` can't open a
|
|
58
|
+
* link and fails with `rfed node identity unknown for <hash>; wait for its
|
|
59
|
+
* announce`. Rather than fail immediately, this sends a `path?` request for
|
|
60
|
+
* the destination and polls `Destination.recall` until the node's
|
|
61
|
+
* path-response announce populates it (or `timeout` elapses), then returns
|
|
62
|
+
* the identity.
|
|
63
|
+
*
|
|
64
|
+
* The rfed node announces every `rfed.*` destination under one shared
|
|
65
|
+
* identity, so a path request for any of them is answered with an announce
|
|
66
|
+
* that makes that identity recallable by destination hash.
|
|
67
|
+
*
|
|
68
|
+
* `onRequest` (if given) is invoked once when the path request is sent, so the
|
|
69
|
+
* CLI can surface "requesting node identity…" progress to the user. Throws
|
|
70
|
+
* the same `rfed node identity unknown for …` error the client raises if
|
|
71
|
+
* still unknown after `timeout` — so callers that skip this helper see no
|
|
72
|
+
* behavior change.
|
|
73
|
+
* @param {import("@reticulum/core").Reticulum} rns A booted Reticulum.
|
|
74
|
+
* @param {Uint8Array} nodeHash An `rfed.*` destination hash of the node.
|
|
75
|
+
* @param {Object} [opts]
|
|
76
|
+
* @param {number} [opts.timeout=15000] Max wait in milliseconds.
|
|
77
|
+
* @param {number} [opts.pollInterval=250] Poll interval in milliseconds.
|
|
78
|
+
* @param {() => void} [opts.onRequest] Invoked once when the path request fires.
|
|
79
|
+
* @returns {Promise<import("@reticulum/core").Identity>}
|
|
80
|
+
*/
|
|
81
|
+
export async function ensureNodeIdentity(
|
|
82
|
+
rns,
|
|
83
|
+
nodeHash,
|
|
84
|
+
{ timeout = DEFAULT_NODE_DISCOVERY_TIMEOUT, pollInterval = 250, onRequest } = {},
|
|
85
|
+
) {
|
|
86
|
+
let identity = await Destination.recall(nodeHash);
|
|
87
|
+
if (identity) return identity;
|
|
88
|
+
// Not yet known — proactively request the destination's path (§7.1). The
|
|
89
|
+
// rfed node answers with a path-response announce (§7.2.4) that populates
|
|
90
|
+
// the recall store; poll until it arrives or the timeout elapses.
|
|
91
|
+
if (onRequest) onRequest();
|
|
92
|
+
await rns.transport.requestPath(nodeHash);
|
|
93
|
+
const deadline = Date.now() + timeout;
|
|
94
|
+
while (Date.now() < deadline) {
|
|
95
|
+
identity = await Destination.recall(nodeHash);
|
|
96
|
+
if (identity) return identity;
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
98
|
+
}
|
|
99
|
+
throw new Error(
|
|
100
|
+
`rfed node identity unknown for ${toHex(nodeHash)}; wait for its announce`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
37
104
|
/**
|
|
38
105
|
* Publish a signed Delta to the rfed channel (§11.1, work doc #6).
|
|
39
106
|
*
|
|
@@ -138,3 +205,73 @@ async function _dacarNodeHash(identity) {
|
|
|
138
205
|
const destHashBuffer = await crypto.subtle.digest("SHA-256", combined);
|
|
139
206
|
return new Uint8Array(destHashBuffer.slice(0, 16));
|
|
140
207
|
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Autodiscover an rfed node from its `rfed.node` announce.
|
|
211
|
+
*
|
|
212
|
+
* Listens for a validated `rfed.node` announce on the live transport and
|
|
213
|
+
* resolves with that announce's `destinationHash` — the rfed node's canonical
|
|
214
|
+
* identifier (the same hash `--node <hash>` accepts and `RFedClient` recalls
|
|
215
|
+
* to open a link).
|
|
216
|
+
*
|
|
217
|
+
* The rfed daemon is an external process (dacar ships only the client); it
|
|
218
|
+
* announces `rfed.node` and the `rfed.channel.*` service destinations under
|
|
219
|
+
* one shared identity. A `dacar.node` announce is a *different* thing — it is
|
|
220
|
+
* a dacar peer advertising its own signing identity (the announce invariant,
|
|
221
|
+
* §11.2.4), not an rfed transport node. Discovery therefore filters for
|
|
222
|
+
* `rfed.node` announces (by `nameHash`), not `dacar.node`, and returns the
|
|
223
|
+
* announced destination hash directly (no derivation — the announce *is* the
|
|
224
|
+
* node hash).
|
|
225
|
+
*
|
|
226
|
+
* @param {Object} opts
|
|
227
|
+
* @param {import("@reticulum/core").Reticulum} opts.rns A booted Reticulum.
|
|
228
|
+
* @param {number} [opts.timeout=30000] Timeout in milliseconds.
|
|
229
|
+
* @returns {Promise<Uint8Array>} The `rfed.node` destination hash of the
|
|
230
|
+
* discovered node.
|
|
231
|
+
* @throws {CliError} If no `rfed.node` announce is received within the timeout.
|
|
232
|
+
*/
|
|
233
|
+
export async function discoverRfedNode({ rns, timeout = 30000 }) {
|
|
234
|
+
if (!rns?.transport?.addEventListener) {
|
|
235
|
+
throw new CliError("RNS transport not available for discovery");
|
|
236
|
+
}
|
|
237
|
+
const encoder = new TextEncoder();
|
|
238
|
+
// nameHash = SHA-256("rfed.node")[:10] — matches `@reticulum/core`'s
|
|
239
|
+
// Destination._computeHashes. The announce event carries this as
|
|
240
|
+
// `detail.nameHash`; filtering on it selects only `rfed.node` announces
|
|
241
|
+
// (the rfed.channel.* services share the identity but have different names).
|
|
242
|
+
const expectedNameHash = new Uint8Array(
|
|
243
|
+
(await crypto.subtle.digest("SHA-256", encoder.encode("rfed.node"))).slice(0, 10),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
return new Promise((resolve, reject) => {
|
|
247
|
+
let settled = false;
|
|
248
|
+
|
|
249
|
+
const onAnnounce = (event) => {
|
|
250
|
+
const detail = event.detail;
|
|
251
|
+
if (!detail?.nameHash) return;
|
|
252
|
+
// Only resolve on rfed.node announces.
|
|
253
|
+
if (toHex(detail.nameHash) !== toHex(expectedNameHash)) return;
|
|
254
|
+
if (settled) return;
|
|
255
|
+
settled = true;
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
rns.transport.removeEventListener("announce", onAnnounce);
|
|
258
|
+
// The announce's destinationHash *is* the rfed node's canonical hash.
|
|
259
|
+
resolve(detail.destinationHash);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
rns.transport.addEventListener("announce", onAnnounce);
|
|
263
|
+
const timer = setTimeout(() => {
|
|
264
|
+
if (settled) return;
|
|
265
|
+
settled = true;
|
|
266
|
+
rns.transport.removeEventListener("announce", onAnnounce);
|
|
267
|
+
reject(
|
|
268
|
+
new CliError(
|
|
269
|
+
`no rfed.node announce received within ${timeout}ms ` +
|
|
270
|
+
"(ensure an rfed node is reachable and announcing)",
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
}, timeout);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
class CliError extends Error {}
|
package/src/cli/store.js
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* - `aliases` — msgpack `[{ hash, names[], note? }]`
|
|
17
17
|
* - `ledger` — msgpack `{ tupleHashHex: { object?, relation?, wildcard?, firstSeen } }`
|
|
18
18
|
* - `identities` — msgpack `{ hashHex: pubKeyBytes }` (durable issuer cache, doc #5)
|
|
19
|
+
* - `outbox` — msgpack `[payloadBytes, ...]` of locally-issued,
|
|
20
|
+
* not-yet-published signed Deltas (doc #8); flushed + cleared
|
|
21
|
+
* by `dacar publish --all`
|
|
19
22
|
*
|
|
20
23
|
* Secret material (the node's own identity private key) uses the adapter's
|
|
21
24
|
* dedicated `loadKey`/`saveKey` slot, matching `@reticulum/core`.
|
|
@@ -221,7 +224,15 @@ export class DacarStore {
|
|
|
221
224
|
const horizon = config?.deletionHorizonDays ?? (await this.loadConfig()).horizonDays;
|
|
222
225
|
const bytes = await this._adapter.get(NS, "state");
|
|
223
226
|
if (bytes && bytes.length) {
|
|
224
|
-
|
|
227
|
+
// `trusted: true` — these are this node's own persisted CRDT snapshot
|
|
228
|
+
// (written by `saveState()` → `toPayload()`), never network bytes.
|
|
229
|
+
// Network Operations arrive as signed Deltas through `DeltaReceiver`
|
|
230
|
+
// (the verify-on-ingest path), not here. Asserting `trusted` silences
|
|
231
|
+
// the audible `fromPayload` footgun warning during normal CLI use.
|
|
232
|
+
return StateVector.fromPayload(bytes, {
|
|
233
|
+
deletionHorizonDays: horizon,
|
|
234
|
+
trusted: true,
|
|
235
|
+
});
|
|
225
236
|
}
|
|
226
237
|
return new StateVector({ deletionHorizonDays: horizon });
|
|
227
238
|
}
|
|
@@ -317,6 +328,39 @@ export class DacarStore {
|
|
|
317
328
|
if (own) keyring.registerSingle(own.identityHash, await own.getPublicKey());
|
|
318
329
|
return keyring;
|
|
319
330
|
}
|
|
331
|
+
|
|
332
|
+
// -- outbox (work doc #8) -----------------------------------------------
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Load the outbox of locally-issued, not-yet-published signed Delta
|
|
336
|
+
* payloads, in the order they were issued. Returns an empty array when no
|
|
337
|
+
* outbox record exists yet.
|
|
338
|
+
* @returns {Promise<Uint8Array[]>}
|
|
339
|
+
*/
|
|
340
|
+
async loadOutbox() {
|
|
341
|
+
const bytes = await this._adapter.get(NS, "outbox");
|
|
342
|
+
if (!bytes) return [];
|
|
343
|
+
let obj;
|
|
344
|
+
try {
|
|
345
|
+
obj = MsgPack.decode(bytes);
|
|
346
|
+
} catch {
|
|
347
|
+
return []; // corrupted -> treat as empty (do not crash the CLI)
|
|
348
|
+
}
|
|
349
|
+
if (!Array.isArray(obj)) return [];
|
|
350
|
+
return obj.filter((p) => p instanceof Uint8Array).map((p) => new Uint8Array(p));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Persist the outbox as a MessagePack array of signed Delta payloads.
|
|
355
|
+
* @param {Uint8Array[]} payloads
|
|
356
|
+
*/
|
|
357
|
+
async saveOutbox(payloads) {
|
|
358
|
+
await this._adapter.set(
|
|
359
|
+
NS,
|
|
360
|
+
"outbox",
|
|
361
|
+
MsgPack.encode(payloads.map((p) => new Uint8Array(p))),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
320
364
|
}
|
|
321
365
|
|
|
322
366
|
/**
|
package/src/crdt.js
CHANGED
|
@@ -256,14 +256,20 @@ export class StateVector {
|
|
|
256
256
|
* > `DeltaReceiver.applyPayloads()` (a batch of signed §5.3 Operations)
|
|
257
257
|
* > instead.
|
|
258
258
|
* >
|
|
259
|
-
* > A one-time `console.warn` is emitted to make this contract audible
|
|
259
|
+
* > A one-time `console.warn` is emitted to make this contract audible —
|
|
260
|
+
* > unless `opts.trusted` is set, which a caller that has already asserted
|
|
261
|
+
* > it is loading its own persisted snapshot (e.g. `DacarStore.loadState`)
|
|
262
|
+
* > passes to keep normal CLI output free of developer-footgun noise.
|
|
260
263
|
* @param {Uint8Array} data
|
|
261
264
|
* @param {Object} [opts]
|
|
262
265
|
* @param {number} [opts.deletionHorizonDays]
|
|
266
|
+
* @param {boolean} [opts.trusted=false] Suppress the audible warning when the
|
|
267
|
+
* caller has asserted the bytes are a trusted-local snapshot (its own
|
|
268
|
+
* store). The JSDoc contract above still applies regardless.
|
|
263
269
|
* @returns {StateVector}
|
|
264
270
|
*/
|
|
265
|
-
static fromPayload(data, { deletionHorizonDays = DEFAULT_DELETION_HORIZON_DAYS } = {}) {
|
|
266
|
-
if (!__trustedLocalWarned) {
|
|
271
|
+
static fromPayload(data, { deletionHorizonDays = DEFAULT_DELETION_HORIZON_DAYS, trusted = false } = {}) {
|
|
272
|
+
if (!trusted && !__trustedLocalWarned) {
|
|
267
273
|
__trustedLocalWarned = true;
|
|
268
274
|
console.warn(
|
|
269
275
|
"StateVector.fromPayload() is trusted-local-only: it performs no " +
|