@dcasegr/skein 0.1.1 → 0.2.8
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 +41 -1
- package/dist/host/arc.d.ts +12 -2
- package/dist/host/arc.js +41 -13
- package/dist/host/cli.d.ts +5 -0
- package/dist/host/cli.js +37 -4
- package/dist/host/fake-teranode.d.ts +29 -0
- package/dist/host/fake-teranode.js +100 -0
- package/dist/host/feeds.d.ts +9 -1
- package/dist/host/feeds.js +30 -5
- package/dist/host/image-chain.d.ts +30 -0
- package/dist/host/image-chain.js +84 -2
- package/dist/host/instances.d.ts +19 -0
- package/dist/host/instances.js +38 -1
- package/dist/host/liveness.d.ts +22 -2
- package/dist/host/liveness.js +42 -5
- package/dist/host/p2p.d.ts +107 -8
- package/dist/host/p2p.js +298 -53
- package/dist/host/router.d.ts +83 -1
- package/dist/host/router.js +248 -33
- package/dist/host/signer.d.ts +6 -0
- package/dist/host/signer.js +8 -0
- package/dist/host/teranode.d.ts +132 -0
- package/dist/host/teranode.js +406 -0
- package/dist/host/testhost.d.ts +5 -0
- package/dist/host/testhost.js +8 -3
- package/dist/host/wasm-kernel.d.ts +9 -0
- package/dist/host/wasm-kernel.js +46 -0
- package/package.json +4 -1
package/dist/host/p2p.js
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
// compressed key), so the key reads straight out of the peer ID.
|
|
17
17
|
// node GossipSub (StrictSign: every message is signed by its publisher's
|
|
18
18
|
// peer key), noise + yamux, TCP and WebSocket listeners (WSS with a
|
|
19
|
-
// certificate), Kademlia DHT off | client | server,
|
|
20
|
-
//
|
|
19
|
+
// certificate), Kademlia DHT off | auto | client | server, AutoNAT,
|
|
20
|
+
// UPnP/NAT-PMP port mapping, mDNS on/off, bootstrap peers kept
|
|
21
|
+
// connected, circuit relays.
|
|
21
22
|
// topics the instance's `libp2p.topics`, its dispatch table's libp2p
|
|
22
23
|
// rows' topics (libp2pConfig) and the topics its apps subscribed
|
|
23
24
|
// (#119: `subscribe {topic, program, fn}` / `unsubscribe {topic}`,
|
|
@@ -53,10 +54,31 @@
|
|
|
53
54
|
// read from a dialled stream handed back (`frames`) to become a
|
|
54
55
|
// message to the instance — the next frame arrives as an entry.
|
|
55
56
|
//
|
|
57
|
+
// The host node (#156, one node): the node of the HOST key (hostKey: on a one-key host the key
|
|
58
|
+
// every instance's node uses, so the instances join it) runs whether or not an instance declares
|
|
59
|
+
// libp2p — the Teranode headers tracker rides it (teranode.ts: the block topic subscribed as a host
|
|
60
|
+
// topic, hostTopic, judged by the tracker, never the front door). Every node carries ONE Kademlia
|
|
61
|
+
// DHT, ours: `skeinDht` on `/skein/kad/<network>/1.0.0` for the overlay's topics (discover()
|
|
62
|
+
// provides and finds on it). There is no Teranode DHT (2026-11-02, plans/teranode-dht-off-2026-11-02.md):
|
|
63
|
+
// the tracker's block-topic peers come from its bootstrap list (SKEIN_TERANODE_BOOTSTRAP, the
|
|
64
|
+
// network's defaults unioned) and GossipSub. Teranode's DHT is the public IPFS DHT; even a kad client
|
|
65
|
+
// crawls it and fills a js-libp2p node's connection cap, which then refuses every newcomer.
|
|
66
|
+
//
|
|
56
67
|
// Host-wide settings (hostP2PConfig): SKEIN_LIBP2P_LISTEN, _BOOTSTRAP, _RELAYS
|
|
57
|
-
// (comma-separated multiaddrs), _DHT (off | client | server
|
|
58
|
-
// _TLS_CERT / _TLS_KEY (PEM files: the WebSocket listener
|
|
59
|
-
// browsers can dial /tls/ws); from the environment, else $SKEIN_HOME/host.env.
|
|
68
|
+
// (comma-separated multiaddrs), _DHT (off | auto | client | server, default auto: the skein DHT's mode), _UPNP
|
|
69
|
+
// (on | off, default on), _MDNS (on | off), _TLS_CERT / _TLS_KEY (PEM files: the WebSocket listener
|
|
70
|
+
// serves TLS, so browsers can dial /tls/ws); from the environment, else $SKEIN_HOME/host.env.
|
|
71
|
+
//
|
|
72
|
+
// The defaults are tuned for discovery (2026-11-01, plans/open-defaults-2026-11-01.md): TCP on every
|
|
73
|
+
// interface, IPv4 and IPv6, on ONE fixed port (DEFAULT_PORT, the same on every install, so a user who
|
|
74
|
+
// opens one port knows which; taken at start: one line, and an OS-chosen port on the same interfaces);
|
|
75
|
+
// the WebSocket listener stays on loopback (plain WS off loopback is dev only). DHT (the skein DHT) `auto` = kad-DHT's
|
|
76
|
+
// own switch (clientMode left unset: a client until the node has a confirmed public address, then a
|
|
77
|
+
// server, and back) with AutoNAT confirming addresses; `client` = only ever a client; `server` = a
|
|
78
|
+
// server from the start (a hosted node behind a known-open port need not wait for AutoNAT). UPnP asks
|
|
79
|
+
// the router to map the port where it allows it (a router that refuses or none found: a line, nothing
|
|
80
|
+
// else); its mapped address is confirmed by AutoNAT before it is announced, so AutoNAT runs whenever
|
|
81
|
+
// UPnP is on or the DHT is auto or server.
|
|
60
82
|
import { existsSync, readFileSync } from "node:fs";
|
|
61
83
|
import { join } from "node:path";
|
|
62
84
|
import { noise } from "@chainsafe/libp2p-noise";
|
|
@@ -65,14 +87,18 @@ import { circuitRelayTransport } from "@libp2p/circuit-relay-v2";
|
|
|
65
87
|
import { privateKeyFromRaw } from "@libp2p/crypto/keys";
|
|
66
88
|
import { gossipsub, TopicValidatorResult } from "@libp2p/gossipsub";
|
|
67
89
|
import { identify } from "@libp2p/identify";
|
|
90
|
+
import { autoNAT } from "@libp2p/autonat";
|
|
68
91
|
import { kadDHT, passthroughMapper, removePrivateAddressesMapper } from "@libp2p/kad-dht";
|
|
92
|
+
import { defaultLogger } from "@libp2p/logger";
|
|
69
93
|
import { mdns } from "@libp2p/mdns";
|
|
70
94
|
import { peerIdFromPrivateKey, peerIdFromString } from "@libp2p/peer-id";
|
|
71
95
|
import { ping } from "@libp2p/ping";
|
|
72
96
|
import { tcp } from "@libp2p/tcp";
|
|
97
|
+
import { uPnPNAT } from "@libp2p/upnp-nat";
|
|
73
98
|
import { isPrivate, lpStream } from "@libp2p/utils";
|
|
74
99
|
import { webSockets } from "@libp2p/websockets";
|
|
75
100
|
import { multiaddr } from "@multiformats/multiaddr";
|
|
101
|
+
import { PrivateKey } from "@bsv/sdk";
|
|
76
102
|
import * as dagCbor from "@ipld/dag-cbor";
|
|
77
103
|
import { createLibp2p } from "libp2p";
|
|
78
104
|
import { CID } from "multiformats/cid";
|
|
@@ -80,8 +106,22 @@ import { sha256 } from "multiformats/hashes/sha2";
|
|
|
80
106
|
import { MESSAGE_KEY_ID, MESSAGE_PROTOCOL as SIGN_PROTOCOL } from "./providers.js";
|
|
81
107
|
import { verifyAnyone } from "../runtime/identity.js";
|
|
82
108
|
import { Liveness } from "./liveness.js";
|
|
83
|
-
/**
|
|
84
|
-
export const
|
|
109
|
+
/** SKEIN_NETWORK's names as the networks' own (the Teranode block topic's, the skein DHT's). */
|
|
110
|
+
export const NETWORK_NAMES = { main: "mainnet", test: "testnet", regtest: "regtest" };
|
|
111
|
+
/** The skein DHT's protocol for a network: the overlay's provider records, never merged with another network's. */
|
|
112
|
+
export const skeinDhtProtocol = (network = "main") => `/skein/kad/${NETWORK_NAMES[network] ?? network}/1.0.0`;
|
|
113
|
+
/**
|
|
114
|
+
* The default TCP port (2026-11-01): one fixed port on every install, so a user who opens one port knows
|
|
115
|
+
* which. 7534 is "SKEI" on a phone keypad; unassigned in the IANA registry (not IPFS's 4001, not
|
|
116
|
+
* Teranode's 9905/9915).
|
|
117
|
+
*/
|
|
118
|
+
export const DEFAULT_PORT = 7534;
|
|
119
|
+
/** The default listeners: TCP on every interface, IPv4 and IPv6, on DEFAULT_PORT; WebSocket on loopback, its port the OS's (plain WS off loopback: dev only). */
|
|
120
|
+
export const DEFAULT_LISTEN = [`/ip4/0.0.0.0/tcp/${DEFAULT_PORT}`, `/ip6/::/tcp/${DEFAULT_PORT}`, "/ip4/127.0.0.1/tcp/0/ws"];
|
|
121
|
+
/** Loopback only, ports chosen by the OS: the router's listeners when it is given no host settings (tests, an embedded router). */
|
|
122
|
+
export const LOOPBACK_LISTEN = ["/ip4/127.0.0.1/tcp/0", "/ip4/127.0.0.1/tcp/0/ws"];
|
|
123
|
+
/** `listen` with DEFAULT_PORT made an OS-chosen port (the fallback when the default port is taken). */
|
|
124
|
+
export const anyPort = (listen) => listen.map((a) => a.replace(new RegExp(`/tcp/${DEFAULT_PORT}(?=/|$)`), "/tcp/0"));
|
|
85
125
|
/** The stream protocol a signed message travels on to an instance (#70). */
|
|
86
126
|
export const MESSAGE_PROTOCOL = "/skein/message/1.0.0";
|
|
87
127
|
/**
|
|
@@ -116,29 +156,69 @@ export async function topicCid(topic) {
|
|
|
116
156
|
}
|
|
117
157
|
// ---------------------------------------------------------------- config
|
|
118
158
|
const list = (s) => (s ?? "").split(/[,\s]+/).map((x) => x.trim()).filter(Boolean);
|
|
119
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* The network's overlay bootstrap peers (SKEIN_NETWORK's names): the hosted Open Exchange node per network,
|
|
161
|
+
* dialled beside SKEIN_LIBP2P_BOOTSTRAP's (unioned, as TERANODE_BOOTSTRAP is with SKEIN_TERANODE_BOOTSTRAP).
|
|
162
|
+
* A node whose own peer id is one of them skips it (discover()). Mainnet and regtest have none yet.
|
|
163
|
+
*/
|
|
164
|
+
export const OVERLAY_BOOTSTRAP = {
|
|
165
|
+
test: ["/dns4/p2p.testnet.openex.dev/tcp/7534/p2p/16Uiu2HAmVN7Cc7TrFGVrbdoV1mMGBF8LQBvPZ5trzgPArfagBRSB"],
|
|
166
|
+
};
|
|
167
|
+
/** SKEIN_LIBP2P_BOOTSTRAP over the network's defaults: unset, the network's; a list, unioned with them; `off`, none at all. */
|
|
168
|
+
export function overlayBootstrap(network, named) {
|
|
169
|
+
if ((named ?? "").trim().toLowerCase() === "off")
|
|
170
|
+
return [];
|
|
171
|
+
return [...new Set([...(OVERLAY_BOOTSTRAP[network] ?? []), ...list(named)])];
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The bootstrap addresses a node dials now: not its own peer id (the hosted node is its network's default
|
|
175
|
+
* bootstrap), not a peer already connected; an address that does not parse is reported and skipped.
|
|
176
|
+
*/
|
|
177
|
+
export function bootstrapDials(bootstrap, self, connected, bad = () => { }) {
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const a of bootstrap) {
|
|
180
|
+
let ma;
|
|
181
|
+
try {
|
|
182
|
+
ma = multiaddr(a);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
bad(a);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const id = ma.getComponents().find((c) => c.name === "p2p")?.value;
|
|
189
|
+
if (id === self || (id && connected.has(id)))
|
|
190
|
+
continue;
|
|
191
|
+
out.push(a);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
/** The host-wide settings: the environment's SKEIN_LIBP2P_* (and SKEIN_NETWORK), else $SKEIN_HOME/host.env's. */
|
|
120
196
|
export function hostP2PConfig(vars, home) {
|
|
121
197
|
const file = {};
|
|
122
198
|
const envFile = home ? join(home, "host.env") : undefined;
|
|
123
199
|
if (envFile && existsSync(envFile)) {
|
|
124
200
|
for (const line of readFileSync(envFile, "utf8").split("\n")) {
|
|
125
|
-
const m = /^\s*(?:export\s+)?(SKEIN_LIBP2P_[A-Z_]
|
|
201
|
+
const m = /^\s*(?:export\s+)?(SKEIN_LIBP2P_[A-Z_]+|SKEIN_NETWORK)=(.*)$/.exec(line);
|
|
126
202
|
if (m)
|
|
127
203
|
file[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
128
204
|
}
|
|
129
205
|
}
|
|
130
206
|
const get = (k) => vars[k] ?? file[k];
|
|
131
|
-
const dht = (get("SKEIN_LIBP2P_DHT") || "
|
|
132
|
-
if (dht !== "off" && dht !== "client" && dht !== "server")
|
|
133
|
-
throw new Error(`SKEIN_LIBP2P_DHT: off | client | server, not ${dht}`);
|
|
207
|
+
const dht = (get("SKEIN_LIBP2P_DHT") || "auto").toLowerCase(); // 2026-11-01: auto by default; 2026-11-02: the skein DHT's mode only (Teranode's is always a client; off = neither)
|
|
208
|
+
if (dht !== "off" && dht !== "auto" && dht !== "client" && dht !== "server")
|
|
209
|
+
throw new Error(`SKEIN_LIBP2P_DHT: off | auto | client | server, not ${dht}`);
|
|
210
|
+
const upnp = (get("SKEIN_LIBP2P_UPNP") || "on").toLowerCase();
|
|
211
|
+
if (!/^(on|off|1|0|true|false|yes|no)$/.test(upnp))
|
|
212
|
+
throw new Error(`SKEIN_LIBP2P_UPNP: on | off, not ${upnp}`);
|
|
134
213
|
const cert = get("SKEIN_LIBP2P_TLS_CERT"), key = get("SKEIN_LIBP2P_TLS_KEY");
|
|
135
214
|
const listen = list(get("SKEIN_LIBP2P_LISTEN"));
|
|
136
215
|
return {
|
|
137
216
|
listen: listen.length ? listen : DEFAULT_LISTEN,
|
|
138
|
-
bootstrap:
|
|
217
|
+
bootstrap: overlayBootstrap(get("SKEIN_NETWORK") || "main", get("SKEIN_LIBP2P_BOOTSTRAP")),
|
|
139
218
|
dht,
|
|
140
219
|
relays: list(get("SKEIN_LIBP2P_RELAYS")),
|
|
141
220
|
mdns: /^(on|1|true|yes)$/i.test(get("SKEIN_LIBP2P_MDNS") ?? ""),
|
|
221
|
+
upnp: /^(on|1|true|yes)$/.test(upnp),
|
|
142
222
|
...(cert && key ? { tls: { cert: readFileSync(cert, "utf8"), key: readFileSync(key, "utf8") } } : {}),
|
|
143
223
|
};
|
|
144
224
|
}
|
|
@@ -358,8 +438,8 @@ const u64 = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigU
|
|
|
358
438
|
export const TOPIC_REQUIRED = "topic required";
|
|
359
439
|
export class P2PHost {
|
|
360
440
|
o;
|
|
361
|
-
/** The liveness tool's state (#138): the apps' liveness and the beats kept
|
|
362
|
-
live
|
|
441
|
+
/** The liveness tool's state (#138): the apps' liveness and the beats kept (persisted in `o.beats` when given). */
|
|
442
|
+
live;
|
|
363
443
|
/** The nodes, by peer ID (#160: one per key). */
|
|
364
444
|
nodes = new Map();
|
|
365
445
|
/** Each handle's node. */
|
|
@@ -369,15 +449,85 @@ export class P2PHost {
|
|
|
369
449
|
/** Each key's declares, in order (one at a time). */
|
|
370
450
|
starting = new Map();
|
|
371
451
|
stopped = false;
|
|
452
|
+
/** The host node's key (hostKey, else a fresh one) and its peer ID. */
|
|
453
|
+
hostKey;
|
|
454
|
+
hostId;
|
|
372
455
|
constructor(o) {
|
|
373
456
|
this.o = o;
|
|
457
|
+
this.live = new Liveness(Date.now, o.beats);
|
|
458
|
+
this.hostKey = o.hostKey ?? PrivateKey.fromRandom();
|
|
459
|
+
this.hostId = peerIdOf(this.hostKey).toString();
|
|
374
460
|
if (o.beat)
|
|
375
461
|
this.live.kept = (h, topic, b) => { if (b.from !== this.peerId(h))
|
|
376
462
|
o.beat(h, topic, b); };
|
|
377
463
|
}
|
|
378
464
|
say(source, line) { this.o.log?.(source, line); }
|
|
379
|
-
/** A node's name in a log line: its one handle (as before #160), else its first. */
|
|
380
|
-
nameOf(n) { return n.handles.keys().next().value ?? n.id; }
|
|
465
|
+
/** A node's name in a log line: its one handle (as before #160), else its first; the host node with none: the router. */
|
|
466
|
+
nameOf(n) { return n.handles.keys().next().value ?? (n.host ? "router" : n.id); }
|
|
467
|
+
// ---------------------------------------------------------------- the host node (#156)
|
|
468
|
+
/**
|
|
469
|
+
* The host node: the node of the host key, started (once) with no handles when no instance has
|
|
470
|
+
* started it yet; an instance on that key that starts later joins it (#160), one that started
|
|
471
|
+
* earlier made it.
|
|
472
|
+
*/
|
|
473
|
+
async hostNode() {
|
|
474
|
+
if (this.stopped)
|
|
475
|
+
throw new Error("libp2p: stopped");
|
|
476
|
+
const key = this.hostId;
|
|
477
|
+
const prev = this.starting.get(key) ?? Promise.resolve();
|
|
478
|
+
const next = prev.catch(() => { }).then(async () => {
|
|
479
|
+
if (this.stopped)
|
|
480
|
+
throw new Error("libp2p: stopped");
|
|
481
|
+
const n = this.nodes.get(key);
|
|
482
|
+
if (n) {
|
|
483
|
+
n.host = true;
|
|
484
|
+
return n;
|
|
485
|
+
}
|
|
486
|
+
const made = await this.start(key, this.hostKey, undefined, undefined);
|
|
487
|
+
if (!made)
|
|
488
|
+
throw new Error("libp2p: stopped");
|
|
489
|
+
return made;
|
|
490
|
+
});
|
|
491
|
+
this.starting.set(key, next);
|
|
492
|
+
void next.catch(() => { }).finally(() => { if (this.starting.get(key) === next)
|
|
493
|
+
this.starting.delete(key); });
|
|
494
|
+
return (await next).node;
|
|
495
|
+
}
|
|
496
|
+
/** The host node's record, once it runs. */
|
|
497
|
+
hostOf() { const n = this.nodes.get(this.hostId); return n?.host ? n : undefined; }
|
|
498
|
+
/**
|
|
499
|
+
* A host topic on the host node (#156: the Teranode block topic): subscribed, each message judged
|
|
500
|
+
* by `take` alone (its verdict GossipSub's), never the front door, never advertised on the skein
|
|
501
|
+
* DHT. Undefined `take`: the topic left.
|
|
502
|
+
*/
|
|
503
|
+
async hostTopic(topic, take) {
|
|
504
|
+
await this.hostNode();
|
|
505
|
+
const n = this.hostOf();
|
|
506
|
+
if (take)
|
|
507
|
+
n.hostTopics.set(topic, take);
|
|
508
|
+
else
|
|
509
|
+
n.hostTopics.delete(topic);
|
|
510
|
+
this.resubscribe(n);
|
|
511
|
+
}
|
|
512
|
+
/** The peers the host node knows subscribe `topic` (GossipSub's view). */
|
|
513
|
+
hostSubscribers(topic) {
|
|
514
|
+
const n = this.hostOf();
|
|
515
|
+
return n ? this.pubsub(n).getSubscribers(topic).map((p) => p.toString()) : [];
|
|
516
|
+
}
|
|
517
|
+
/** The host node's skein DHT's mode now (client | server; undefined: off or no host node yet). */
|
|
518
|
+
hostDhtMode() {
|
|
519
|
+
return this.hostDht()?.getMode();
|
|
520
|
+
}
|
|
521
|
+
/** The host node's confirmed public addresses (AutoNAT's, or UPnP's once AutoNAT confirmed them): libp2p announces only confirmed ones; relay addresses aside. */
|
|
522
|
+
hostPublicAddrs() {
|
|
523
|
+
const n = this.hostOf();
|
|
524
|
+
return n ? n.node.getMultiaddrs().filter((m) => !isPrivateAddr(m) && !m.toString().includes("/p2p-circuit")).map(String) : [];
|
|
525
|
+
}
|
|
526
|
+
/** The host node's DHT service, the skein DHT (undefined: SKEIN_LIBP2P_DHT=off, or no host node yet). */
|
|
527
|
+
hostDht() {
|
|
528
|
+
const n = this.hostOf();
|
|
529
|
+
return n ? dhtOf(n.node) : undefined;
|
|
530
|
+
}
|
|
381
531
|
/** The node of an instance, if it has one. */
|
|
382
532
|
node(handle) { return this.byHandle.get(handle)?.node; }
|
|
383
533
|
/** An instance's peer ID (derived: whether or not its node runs). */
|
|
@@ -412,7 +562,7 @@ export class P2PHost {
|
|
|
412
562
|
const n = this.byHandle.get(handle) ?? this.nodes.get(key);
|
|
413
563
|
if (n)
|
|
414
564
|
return this.reconfigure(n, handle, config);
|
|
415
|
-
await this.start(key, handle, config);
|
|
565
|
+
await this.start(key, this.o.keyOf(handle), handle, config);
|
|
416
566
|
});
|
|
417
567
|
this.starting.set(key, next);
|
|
418
568
|
// Only clears the slot: the error reaches this call's caller through `await next` below.
|
|
@@ -461,9 +611,9 @@ export class P2PHost {
|
|
|
461
611
|
}
|
|
462
612
|
/** The topics `handle` takes: its config's and its apps' liveness topics (#138). */
|
|
463
613
|
takes(handle, h) { return [...h.config.topics, ...this.live.topics(handle)]; }
|
|
464
|
-
/** Subscribe what the handles' configs and liveness tools (#138) take, unsubscribe the rest: the topics added and removed. */
|
|
614
|
+
/** Subscribe what the handles' configs and liveness tools (#138) take, and the host's own topics (#156), unsubscribe the rest: the topics added and removed. */
|
|
465
615
|
resubscribe(n) {
|
|
466
|
-
const want = new Set([...n.handles].flatMap(([handle, h]) => this.takes(handle, h)));
|
|
616
|
+
const want = new Set([...[...n.handles].flatMap(([handle, h]) => this.takes(handle, h)), ...n.hostTopics.keys()]);
|
|
467
617
|
const ps = this.pubsub(n);
|
|
468
618
|
const removed = [...n.subscribed].filter((t) => !want.has(t));
|
|
469
619
|
const added = [...want].filter((t) => !n.subscribed.has(t));
|
|
@@ -545,20 +695,26 @@ export class P2PHost {
|
|
|
545
695
|
async handleProtocol(n, protocol) {
|
|
546
696
|
await n.node.handle(protocol, (stream, connection) => { void this.serve(n, protocol, stream, connection.remotePeer); }, { runOnLimitedConnection: true });
|
|
547
697
|
}
|
|
548
|
-
|
|
698
|
+
/** A node for `key` (its private key `pk`): with `handle` and its config, or the host node with none (#156). */
|
|
699
|
+
async start(key, pk, handle, config) {
|
|
549
700
|
const h = this.o.host;
|
|
550
|
-
const n = {
|
|
551
|
-
|
|
701
|
+
const n = {
|
|
702
|
+
id: key, node: undefined, handles: new Map(handle && config ? [[handle, { config, streams: new Map(), beats: new Map() }]] : []),
|
|
703
|
+
seqnos: new Map(), subscribed: new Set(), handled: new Set(), hostTopics: new Map(), ...(key === this.hostId ? { host: true } : {}),
|
|
704
|
+
};
|
|
705
|
+
const listen = [...(config?.listen ?? h.listen), ...h.relays.map((r) => `${r.replace(/\/+$/, "")}/p2p-circuit`)];
|
|
552
706
|
// An address that does not parse counts as private here; start() reports it when it is dialled or listened on.
|
|
553
|
-
const
|
|
707
|
+
const allPrivate = (xs) => xs.every((a) => { try {
|
|
554
708
|
return isPrivateAddr(multiaddr(a));
|
|
555
709
|
}
|
|
556
710
|
catch {
|
|
557
711
|
return true;
|
|
558
712
|
} });
|
|
713
|
+
const privateDev = allPrivate([...h.bootstrap, ...listen]);
|
|
559
714
|
const services = {
|
|
560
715
|
identify: identify(),
|
|
561
|
-
|
|
716
|
+
// #156: the DHT pings a new peer (before it enters its table) beside the other pings; 4 outbound streams as before.
|
|
717
|
+
ping: ping({ maxOutboundStreams: 4 }),
|
|
562
718
|
pubsub: gossipsub({
|
|
563
719
|
globalSignaturePolicy: "StrictSign",
|
|
564
720
|
allowPublishToZeroTopicPeers: true,
|
|
@@ -579,36 +735,73 @@ export class P2PHost {
|
|
|
579
735
|
},
|
|
580
736
|
}),
|
|
581
737
|
};
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
738
|
+
// #156: one DHT, ours (the overlay topics'); 2026-11-02: no Teranode DHT (the public IPFS DHT: even a client fills the connection cap).
|
|
739
|
+
if (h.dht !== "off") {
|
|
740
|
+
services.skeinDht = kadDHT({
|
|
741
|
+
protocol: skeinDhtProtocol(h.network), ...dhtClientMode(h.dht), peerInfoMapper: privateDev ? passthroughMapper : removePrivateAddressesMapper,
|
|
742
|
+
logPrefix: "libp2p:dht-skein", datastorePrefix: "/dht-skein", metricsPrefix: "libp2p_dht_skein",
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
// 2026-11-01: AutoNAT confirms the node's public addresses (DHT auto flips to server on one; UPnP's mapped address needs it).
|
|
746
|
+
if (h.upnp || h.dht === "auto" || h.dht === "server")
|
|
747
|
+
services.autoNAT = autoNAT();
|
|
748
|
+
if (h.upnp)
|
|
749
|
+
services.upnpNAT = uPnPNAT();
|
|
750
|
+
const name = handle ?? "router";
|
|
751
|
+
const create = (addrs) => createLibp2p({
|
|
752
|
+
privateKey: libp2pKey(pk),
|
|
753
|
+
addresses: { listen: addrs },
|
|
587
754
|
transports: [tcp(), webSockets(h.tls ? { https: { cert: h.tls.cert, key: h.tls.key } } : {}), ...(h.relays.length ? [circuitRelayTransport()] : [])],
|
|
588
755
|
connectionEncrypters: [noise()],
|
|
589
756
|
streamMuxers: [yamux()],
|
|
590
757
|
peerDiscovery: h.mdns ? [mdns()] : [],
|
|
591
758
|
services: services,
|
|
759
|
+
...(h.upnp ? { logger: upnpTold((line) => this.say(name, line)) } : {}),
|
|
760
|
+
start: false,
|
|
592
761
|
});
|
|
762
|
+
let node = await create(listen);
|
|
763
|
+
try {
|
|
764
|
+
await node.start();
|
|
765
|
+
}
|
|
766
|
+
catch (e) {
|
|
767
|
+
try {
|
|
768
|
+
await node.stop();
|
|
769
|
+
}
|
|
770
|
+
catch { /* the failed start's listeners closed as far as they go */ }
|
|
771
|
+
// 2026-11-01: the default port taken (another host, another node of this one): an OS-chosen port on the same interfaces.
|
|
772
|
+
// An address SKEIN_LIBP2P_LISTEN (or the instance) names is honoured as named: its failure stops the node, as before.
|
|
773
|
+
const defaulted = !config?.listen && h.listen.join(" ") === DEFAULT_LISTEN.join(" ");
|
|
774
|
+
if (!defaulted || !/EADDRINUSE/.test(e.message))
|
|
775
|
+
throw e;
|
|
776
|
+
const fallback = anyPort(listen);
|
|
777
|
+
this.say(name, `libp2p: port ${DEFAULT_PORT} is taken: listening on an OS-chosen port instead`);
|
|
778
|
+
node = await create(fallback);
|
|
779
|
+
await node.start();
|
|
780
|
+
}
|
|
593
781
|
n.node = node;
|
|
594
782
|
if (this.stopped) {
|
|
595
783
|
await node.stop();
|
|
596
784
|
return undefined;
|
|
597
785
|
}
|
|
598
786
|
this.nodes.set(key, n);
|
|
599
|
-
|
|
787
|
+
if (handle)
|
|
788
|
+
this.byHandle.set(handle, n);
|
|
600
789
|
if (h.mdns)
|
|
601
790
|
node.addEventListener("peer:discovery", (e) => { void node.dial(e.detail.id).catch((err) => this.say(this.nameOf(n), `libp2p: mDNS peer ${e.detail.id}: ${err.message}`)); });
|
|
602
791
|
this.resubscribe(n);
|
|
603
792
|
const host = this.o.hostProtocols ?? {};
|
|
604
|
-
for (const protocol of config
|
|
793
|
+
for (const protocol of (config?.protocols ?? []).filter((p) => inboundRow(p, host))) {
|
|
605
794
|
n.handled.add(protocol);
|
|
606
795
|
await this.handleProtocol(n, protocol);
|
|
607
796
|
}
|
|
608
797
|
await this.handleProtocol(n, MESSAGE_PROTOCOL);
|
|
609
798
|
for (const [protocol, f] of Object.entries(host))
|
|
610
799
|
await this.handleHostProtocol(n, protocol, f);
|
|
611
|
-
|
|
800
|
+
const dhts = (h.dht === "off" ? "DHT off" : `DHT ${h.dht} (${skeinDhtProtocol(h.network)})`) + `; UPnP ${h.upnp ? "on" : "off"}; bootstrap ${h.bootstrap.join(", ") || "none"}`;
|
|
801
|
+
if (handle && config)
|
|
802
|
+
this.say(handle, `libp2p: peer ${node.peerId} listening on ${node.getMultiaddrs().map(String).join(" ") || "(nothing)"}; topics ${config.topics.join(", ") || "none"}; protocols ${config.protocols.join(", ") || "none"}; ${dhts}`);
|
|
803
|
+
else
|
|
804
|
+
this.say("router", `libp2p: the host node, peer ${node.peerId} listening on ${node.getMultiaddrs().map(String).join(" ") || "(nothing)"}; ${dhts}`);
|
|
612
805
|
const discover = () => void this.discover(n).catch((e) => this.say(this.nameOf(n), `libp2p: discovery: ${e.message}`));
|
|
613
806
|
setTimeout(discover, 200);
|
|
614
807
|
n.timer = setInterval(discover, this.o.discoveryMs ?? 30_000);
|
|
@@ -620,28 +813,19 @@ export class P2PHost {
|
|
|
620
813
|
const node = n.node;
|
|
621
814
|
const name = this.nameOf(n);
|
|
622
815
|
const connected = new Set(node.getConnections().map((c) => c.remotePeer.toString()));
|
|
623
|
-
for (const a of this.o.host.bootstrap) {
|
|
624
|
-
|
|
625
|
-
try {
|
|
626
|
-
ma = multiaddr(a);
|
|
627
|
-
}
|
|
628
|
-
catch {
|
|
629
|
-
this.say(name, `libp2p: bad bootstrap address ${a}`);
|
|
630
|
-
continue;
|
|
631
|
-
}
|
|
632
|
-
const id = ma.getComponents().find((c) => c.name === "p2p")?.value;
|
|
633
|
-
if (id === node.peerId.toString() || (id && connected.has(id)))
|
|
634
|
-
continue;
|
|
635
|
-
await node.dial(ma).catch((e) => this.say(name, `libp2p: bootstrap ${a}: ${e.message}`));
|
|
816
|
+
for (const a of bootstrapDials(this.o.host.bootstrap, node.peerId.toString(), connected, (b) => this.say(name, `libp2p: bad bootstrap address ${b}`))) {
|
|
817
|
+
await node.dial(multiaddr(a)).catch((e) => this.say(name, `libp2p: bootstrap ${a}: ${e.message}`));
|
|
636
818
|
}
|
|
637
|
-
|
|
819
|
+
// #156: the skein DHT (the node's one DHT); the host's own topics are the tracker's.
|
|
820
|
+
const dht = dhtOf(node);
|
|
821
|
+
if (!dht)
|
|
638
822
|
return;
|
|
639
|
-
for (const topic of [...n.subscribed]) {
|
|
823
|
+
for (const topic of [...n.subscribed].filter((t) => !n.hostTopics.has(t))) {
|
|
640
824
|
const cid = await topicCid(topic);
|
|
641
|
-
await
|
|
825
|
+
await drain(dht.provide(cid, { signal: AbortSignal.timeout(20_000) })).catch((e) => { if (!timedOut(e))
|
|
642
826
|
this.say(name, `libp2p: provide ${topic}: ${e.message}`); });
|
|
643
827
|
try {
|
|
644
|
-
for await (const p of
|
|
828
|
+
for await (const p of providersOf(dht, cid, { signal: AbortSignal.timeout(20_000) })) {
|
|
645
829
|
if (p.id.equals(node.peerId) || node.getConnections(p.id).length)
|
|
646
830
|
continue;
|
|
647
831
|
await node.dial(p.multiaddrs.length ? p.multiaddrs : p.id).catch((e) => this.say(name, `libp2p: ${topic} provider ${p.id}: ${e.message}`));
|
|
@@ -666,6 +850,19 @@ export class P2PHost {
|
|
|
666
850
|
async validate(n, topic, _source, msg) {
|
|
667
851
|
if (msg.type !== "signed")
|
|
668
852
|
return TopicValidatorResult.Reject;
|
|
853
|
+
// #156: a host topic is judged by its handler alone (the tracker's), never the front door.
|
|
854
|
+
const own = n.hostTopics.get(topic);
|
|
855
|
+
if (own) {
|
|
856
|
+
let v;
|
|
857
|
+
try {
|
|
858
|
+
v = own(msg.data, msg.from);
|
|
859
|
+
}
|
|
860
|
+
catch (e) {
|
|
861
|
+
this.say(this.nameOf(n), `libp2p: ${topic}: ${e.message}`);
|
|
862
|
+
v = "ignore";
|
|
863
|
+
} // the host failing is not the message's fault
|
|
864
|
+
return v === "accept" ? TopicValidatorResult.Accept : v === "reject" ? TopicValidatorResult.Reject : TopicValidatorResult.Ignore;
|
|
865
|
+
}
|
|
669
866
|
const takers = [...n.handles].filter(([handle, h]) => h.config.topics.includes(topic) || this.live.takes(handle, topic));
|
|
670
867
|
if (!takers.length)
|
|
671
868
|
return TopicValidatorResult.Ignore;
|
|
@@ -948,7 +1145,7 @@ export class P2PHost {
|
|
|
948
1145
|
clearInterval(b.timer);
|
|
949
1146
|
hs?.beats.clear();
|
|
950
1147
|
this.live.drop(handle);
|
|
951
|
-
if (n.handles.size) {
|
|
1148
|
+
if (n.handles.size || n.host) {
|
|
952
1149
|
this.resubscribe(n);
|
|
953
1150
|
await this.rehandle(n).catch((e) => this.say(handle, `libp2p: leaving its key's node: ${e.message}`));
|
|
954
1151
|
this.say(handle, `libp2p: left the node of its key ${n.id} (${n.handles.size} instances on it)`);
|
|
@@ -972,7 +1169,7 @@ export class P2PHost {
|
|
|
972
1169
|
for (const b of hs.beats.values())
|
|
973
1170
|
clearInterval(b.timer);
|
|
974
1171
|
this.byHandle.delete(handle);
|
|
975
|
-
this.live.
|
|
1172
|
+
this.live.forget(handle); // the host stopping: its stored beats stay for the next start
|
|
976
1173
|
}
|
|
977
1174
|
n.handles.clear();
|
|
978
1175
|
this.nodes.delete(n.id);
|
|
@@ -987,7 +1184,7 @@ export class P2PHost {
|
|
|
987
1184
|
}
|
|
988
1185
|
}
|
|
989
1186
|
/** A multiaddr on a loopback or private network (dev: the DHT keeps such addresses). */
|
|
990
|
-
function isPrivateAddr(ma) {
|
|
1187
|
+
export function isPrivateAddr(ma) {
|
|
991
1188
|
const c = ma.getComponents().find((x) => x.name.startsWith("dns"));
|
|
992
1189
|
if (c?.value)
|
|
993
1190
|
return c.value === "localhost" || c.value.endsWith(".localhost");
|
|
@@ -1000,3 +1197,51 @@ function isPrivateAddr(ma) {
|
|
|
1000
1197
|
}
|
|
1001
1198
|
/** An AbortSignal.timeout's abort: the DHT's 20-second rounds end this way, and the next round tries again. */
|
|
1002
1199
|
const timedOut = (e) => e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError");
|
|
1200
|
+
/**
|
|
1201
|
+
* kad-DHT's clientMode for a mode of the skein DHT (2026-11-01; @libp2p/kad-dht 16.4.5 kad-dht.js): `auto` leaves it
|
|
1202
|
+
* unset — the DHT's own switch (a client, a server while the node has a confirmed public address);
|
|
1203
|
+
* `client` true — only ever a client; `server` false — a server from its start (its start sets the mode),
|
|
1204
|
+
* never switched back. An explicit false is NOT auto in this version: the switch listens only when unset.
|
|
1205
|
+
*/
|
|
1206
|
+
export function dhtClientMode(mode) {
|
|
1207
|
+
return mode === "auto" ? {} : { clientMode: mode === "client" };
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* The libp2p logger, with the UPnP service's outcome told once per distinct line (2026-11-01): the gateway
|
|
1211
|
+
* search's result, the public IP found, each mapping made or refused, an error. The service reports its
|
|
1212
|
+
* outcome nowhere else (its log only); every other component's log is the default, untouched.
|
|
1213
|
+
*/
|
|
1214
|
+
export function upnpTold(tell) {
|
|
1215
|
+
const base = defaultLogger();
|
|
1216
|
+
const said = new Set();
|
|
1217
|
+
const fmt = (f, args) => { let i = 0; return String(f).replace(/%[sdioOjaepcbt]/g, () => { const a = args[i++]; return a instanceof Error ? a.message : String(a); }); };
|
|
1218
|
+
const once = (line) => { if (!said.has(line)) {
|
|
1219
|
+
said.add(line);
|
|
1220
|
+
tell(line);
|
|
1221
|
+
} };
|
|
1222
|
+
const outcome = /^(gateway search finished|discovered public IP|created mapping)/;
|
|
1223
|
+
const wrap = (log) => Object.assign((f, ...args) => { log(f, ...args); if (outcome.test(String(f)))
|
|
1224
|
+
once(`libp2p: UPnP ${fmt(f, args)}`); }, {
|
|
1225
|
+
error: (f, ...args) => { log.error(f, ...args); once(`libp2p: UPnP ${fmt(f, args)}`); },
|
|
1226
|
+
trace: log.trace.bind(log),
|
|
1227
|
+
enabled: log.enabled,
|
|
1228
|
+
newScope: (s) => wrap(log.newScope(s)),
|
|
1229
|
+
});
|
|
1230
|
+
return { forComponent: (c) => c.startsWith("libp2p:upnp-nat") ? wrap(base.forComponent(c)) : base.forComponent(c) };
|
|
1231
|
+
}
|
|
1232
|
+
/** A node's DHT service, the skein DHT (#156), or undefined (SKEIN_LIBP2P_DHT=off). */
|
|
1233
|
+
export function dhtOf(node) {
|
|
1234
|
+
return node.services.skeinDht;
|
|
1235
|
+
}
|
|
1236
|
+
/** The providers a DHT finds for `cid` (its PROVIDER events' peers), on that DHT alone. */
|
|
1237
|
+
export async function* providersOf(dht, cid, options = {}) {
|
|
1238
|
+
for await (const e of dht.findProviders(cid, options))
|
|
1239
|
+
if (e.name === "PROVIDER")
|
|
1240
|
+
yield* e.providers;
|
|
1241
|
+
}
|
|
1242
|
+
/** Run a DHT operation's events to the end. */
|
|
1243
|
+
export async function drain(events) { for await (const _ of events) { /* the events: not needed */ } }
|
|
1244
|
+
/** A DHT's routing table size (undefined: no such DHT). */
|
|
1245
|
+
export function tableSize(dht) {
|
|
1246
|
+
return dht?.routingTable?.size;
|
|
1247
|
+
}
|