@decentnetwork/peer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +31 -0
- package/README.md +97 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +245 -0
- package/dist/compat/address.d.ts +13 -0
- package/dist/compat/address.js +69 -0
- package/dist/compat/bootstrap.d.ts +29 -0
- package/dist/compat/bootstrap.js +178 -0
- package/dist/compat/dht.d.ts +3 -0
- package/dist/compat/dht.js +9 -0
- package/dist/compat/express.d.ts +21 -0
- package/dist/compat/express.js +263 -0
- package/dist/compat/friend.d.ts +4 -0
- package/dist/compat/friend.js +12 -0
- package/dist/compat/net-crypto.d.ts +84 -0
- package/dist/compat/net-crypto.js +278 -0
- package/dist/compat/packet.d.ts +55 -0
- package/dist/compat/packet.js +154 -0
- package/dist/compat/session.d.ts +3 -0
- package/dist/compat/session.js +7 -0
- package/dist/compat/tcp-relay-pool.d.ts +85 -0
- package/dist/compat/tcp-relay-pool.js +342 -0
- package/dist/compat/tcp-relay.d.ts +96 -0
- package/dist/compat/tcp-relay.js +489 -0
- package/dist/compat/text.d.ts +3 -0
- package/dist/compat/text.js +8 -0
- package/dist/compat/tox-dht-crypto.d.ts +18 -0
- package/dist/compat/tox-dht-crypto.js +69 -0
- package/dist/compat/tox-onion.d.ts +66 -0
- package/dist/compat/tox-onion.js +172 -0
- package/dist/crypto/box.d.ts +1 -0
- package/dist/crypto/box.js +3 -0
- package/dist/crypto/keypair.d.ts +5 -0
- package/dist/crypto/keypair.js +37 -0
- package/dist/crypto/nonce.d.ts +1 -0
- package/dist/crypto/nonce.js +1 -0
- package/dist/crypto/sign.d.ts +1 -0
- package/dist/crypto/sign.js +3 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/peer.d.ts +45 -0
- package/dist/peer.js +3425 -0
- package/dist/runtime/errors.d.ts +3 -0
- package/dist/runtime/errors.js +6 -0
- package/dist/runtime/events.d.ts +4 -0
- package/dist/runtime/events.js +1 -0
- package/dist/runtime/lifecycle.d.ts +7 -0
- package/dist/runtime/lifecycle.js +12 -0
- package/dist/store/config.d.ts +2 -0
- package/dist/store/config.js +1 -0
- package/dist/store/friends.d.ts +13 -0
- package/dist/store/friends.js +1 -0
- package/dist/store/state.d.ts +3 -0
- package/dist/store/state.js +1 -0
- package/dist/transport/socket.d.ts +4 -0
- package/dist/transport/socket.js +1 -0
- package/dist/transport/tcp.d.ts +3 -0
- package/dist/transport/tcp.js +5 -0
- package/dist/transport/udp.d.ts +24 -0
- package/dist/transport/udp.js +90 -0
- package/dist/types/bootstrap.d.ts +2 -0
- package/dist/types/bootstrap.js +1 -0
- package/dist/types/dht.d.ts +3 -0
- package/dist/types/dht.js +1 -0
- package/dist/types/friend.d.ts +1 -0
- package/dist/types/friend.js +1 -0
- package/dist/types/message.d.ts +1 -0
- package/dist/types/message.js +1 -0
- package/dist/types/peer.d.ts +51 -0
- package/dist/types/peer.js +1 -0
- package/dist/types/session.d.ts +1 -0
- package/dist/types/session.js +1 -0
- package/dist/utils/base58.d.ts +2 -0
- package/dist/utils/base58.js +51 -0
- package/dist/utils/bytes.d.ts +4 -0
- package/dist/utils/bytes.js +31 -0
- package/docs/INSTALL.md +103 -0
- package/docs/USAGE_GUIDE.md +724 -0
- package/package.json +77 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export declare const PACKET_TYPE_USERINFO = 3;
|
|
2
|
+
export declare const PACKET_TYPE_FRIEND_REQUEST = 6;
|
|
3
|
+
export declare const PACKET_TYPE_MESSAGE = 33;
|
|
4
|
+
export type UserInfoPacket = {
|
|
5
|
+
type: typeof PACKET_TYPE_USERINFO;
|
|
6
|
+
avatar: boolean;
|
|
7
|
+
name: string;
|
|
8
|
+
descr: string;
|
|
9
|
+
phone: string;
|
|
10
|
+
gender: string;
|
|
11
|
+
email: string;
|
|
12
|
+
region: string;
|
|
13
|
+
};
|
|
14
|
+
export type FriendRequestPacket = {
|
|
15
|
+
type: typeof PACKET_TYPE_FRIEND_REQUEST;
|
|
16
|
+
name: string;
|
|
17
|
+
descr: string;
|
|
18
|
+
hello: string;
|
|
19
|
+
};
|
|
20
|
+
export type FriendMessagePacket = {
|
|
21
|
+
type: typeof PACKET_TYPE_MESSAGE;
|
|
22
|
+
ext?: string;
|
|
23
|
+
data: Uint8Array;
|
|
24
|
+
};
|
|
25
|
+
export type CarrierPacket = UserInfoPacket | FriendRequestPacket | FriendMessagePacket;
|
|
26
|
+
/**
|
|
27
|
+
* Encode a Carrier `userinfo` packet (FB type 3). The Carrier C SDK sends
|
|
28
|
+
* this every time the local user's name / status message / profile fields
|
|
29
|
+
* change, transported via toxcore's PACKET_ID_STATUSMESSAGE (49).
|
|
30
|
+
*
|
|
31
|
+
* Field layout per src/carrier/packet.fbs:
|
|
32
|
+
* field 0 = avatar : bool (default false)
|
|
33
|
+
* field 1 = name : string
|
|
34
|
+
* field 2 = descr : string
|
|
35
|
+
* field 3 = phone : string
|
|
36
|
+
* field 4 = gender : string
|
|
37
|
+
* field 5 = email : string
|
|
38
|
+
* field 6 = region : string
|
|
39
|
+
*/
|
|
40
|
+
export declare function encodeUserInfoPacket(opts: {
|
|
41
|
+
avatar?: boolean;
|
|
42
|
+
name?: string;
|
|
43
|
+
descr?: string;
|
|
44
|
+
phone?: string;
|
|
45
|
+
gender?: string;
|
|
46
|
+
email?: string;
|
|
47
|
+
region?: string;
|
|
48
|
+
}): Uint8Array;
|
|
49
|
+
export declare function encodeFriendRequestPacket(opts: {
|
|
50
|
+
name?: string;
|
|
51
|
+
descr?: string;
|
|
52
|
+
hello?: string;
|
|
53
|
+
}): Uint8Array;
|
|
54
|
+
export declare function encodeFriendMessagePacket(data: Uint8Array | string, ext?: string): Uint8Array;
|
|
55
|
+
export declare function decodeCarrierPacket(bytes: Uint8Array): CarrierPacket;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Builder, ByteBuffer, Encoding } from "flatbuffers";
|
|
2
|
+
export const PACKET_TYPE_USERINFO = 3;
|
|
3
|
+
export const PACKET_TYPE_FRIEND_REQUEST = 6;
|
|
4
|
+
export const PACKET_TYPE_MESSAGE = 33;
|
|
5
|
+
// Body type indices into FlatBuffers `anybody` union (carrier/packet.fbs).
|
|
6
|
+
// The .fbs declares: userinfo, friendreq, friendmsg, invitereq, invitersp,
|
|
7
|
+
// bulkmsg — flatbuffers maps those to 1, 2, 3, 4, 5, 6 (NONE = 0).
|
|
8
|
+
const ANYBODY_USERINFO = 1;
|
|
9
|
+
const ANYBODY_FRIENDREQ = 2;
|
|
10
|
+
const ANYBODY_FRIENDMSG = 3;
|
|
11
|
+
/**
|
|
12
|
+
* Encode a Carrier `userinfo` packet (FB type 3). The Carrier C SDK sends
|
|
13
|
+
* this every time the local user's name / status message / profile fields
|
|
14
|
+
* change, transported via toxcore's PACKET_ID_STATUSMESSAGE (49).
|
|
15
|
+
*
|
|
16
|
+
* Field layout per src/carrier/packet.fbs:
|
|
17
|
+
* field 0 = avatar : bool (default false)
|
|
18
|
+
* field 1 = name : string
|
|
19
|
+
* field 2 = descr : string
|
|
20
|
+
* field 3 = phone : string
|
|
21
|
+
* field 4 = gender : string
|
|
22
|
+
* field 5 = email : string
|
|
23
|
+
* field 6 = region : string
|
|
24
|
+
*/
|
|
25
|
+
export function encodeUserInfoPacket(opts) {
|
|
26
|
+
const builder = new Builder(256);
|
|
27
|
+
const name = builder.createString(opts.name ?? "");
|
|
28
|
+
const descr = builder.createString(opts.descr ?? "");
|
|
29
|
+
const phone = builder.createString(opts.phone ?? "");
|
|
30
|
+
const gender = builder.createString(opts.gender ?? "");
|
|
31
|
+
const email = builder.createString(opts.email ?? "");
|
|
32
|
+
const region = builder.createString(opts.region ?? "");
|
|
33
|
+
builder.startObject(7);
|
|
34
|
+
// Add high field numbers first so vtable reflects all slots.
|
|
35
|
+
builder.addFieldOffset(6, region, 0);
|
|
36
|
+
builder.addFieldOffset(5, email, 0);
|
|
37
|
+
builder.addFieldOffset(4, gender, 0);
|
|
38
|
+
builder.addFieldOffset(3, phone, 0);
|
|
39
|
+
builder.addFieldOffset(2, descr, 0);
|
|
40
|
+
builder.addFieldOffset(1, name, 0);
|
|
41
|
+
if (opts.avatar) {
|
|
42
|
+
builder.addFieldInt8(0, 1, 0);
|
|
43
|
+
}
|
|
44
|
+
const userinfo = builder.endObject();
|
|
45
|
+
return finishCarrierPacket(builder, PACKET_TYPE_USERINFO, ANYBODY_USERINFO, userinfo);
|
|
46
|
+
}
|
|
47
|
+
export function encodeFriendRequestPacket(opts) {
|
|
48
|
+
const builder = new Builder(256);
|
|
49
|
+
const name = builder.createString(opts.name ?? "");
|
|
50
|
+
const descr = builder.createString(opts.descr ?? "");
|
|
51
|
+
const hello = builder.createString(opts.hello ?? "");
|
|
52
|
+
builder.startObject(3);
|
|
53
|
+
builder.addFieldOffset(2, hello, 0);
|
|
54
|
+
builder.addFieldOffset(1, descr, 0);
|
|
55
|
+
builder.addFieldOffset(0, name, 0);
|
|
56
|
+
const friendReq = builder.endObject();
|
|
57
|
+
return finishCarrierPacket(builder, PACKET_TYPE_FRIEND_REQUEST, ANYBODY_FRIENDREQ, friendReq);
|
|
58
|
+
}
|
|
59
|
+
export function encodeFriendMessagePacket(data, ext) {
|
|
60
|
+
const builder = new Builder(256);
|
|
61
|
+
const messageData = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
62
|
+
const msg = builder.createByteVector(messageData);
|
|
63
|
+
const extOffset = ext ? builder.createString(ext) : 0;
|
|
64
|
+
builder.startObject(2);
|
|
65
|
+
builder.addFieldOffset(1, msg, 0);
|
|
66
|
+
if (extOffset) {
|
|
67
|
+
builder.addFieldOffset(0, extOffset, 0);
|
|
68
|
+
}
|
|
69
|
+
const friendMsg = builder.endObject();
|
|
70
|
+
return finishCarrierPacket(builder, PACKET_TYPE_MESSAGE, ANYBODY_FRIENDMSG, friendMsg);
|
|
71
|
+
}
|
|
72
|
+
export function decodeCarrierPacket(bytes) {
|
|
73
|
+
const bb = new ByteBuffer(bytes);
|
|
74
|
+
const root = bb.readInt32(bb.position()) + bb.position();
|
|
75
|
+
const packet = { bb, bb_pos: root };
|
|
76
|
+
const type = readUint8Field(packet, 0);
|
|
77
|
+
const bodyType = readUint8Field(packet, 1);
|
|
78
|
+
const body = readTableField(packet, 2);
|
|
79
|
+
if (!body) {
|
|
80
|
+
throw new Error("carrier packet body is missing");
|
|
81
|
+
}
|
|
82
|
+
if (type === PACKET_TYPE_USERINFO && bodyType === ANYBODY_USERINFO) {
|
|
83
|
+
return {
|
|
84
|
+
type,
|
|
85
|
+
avatar: readBoolField(body, 0),
|
|
86
|
+
name: readStringField(body, 1) ?? "",
|
|
87
|
+
descr: readStringField(body, 2) ?? "",
|
|
88
|
+
phone: readStringField(body, 3) ?? "",
|
|
89
|
+
gender: readStringField(body, 4) ?? "",
|
|
90
|
+
email: readStringField(body, 5) ?? "",
|
|
91
|
+
region: readStringField(body, 6) ?? ""
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (type === PACKET_TYPE_FRIEND_REQUEST && bodyType === ANYBODY_FRIENDREQ) {
|
|
95
|
+
return {
|
|
96
|
+
type,
|
|
97
|
+
name: readStringField(body, 0) ?? "",
|
|
98
|
+
descr: readStringField(body, 1) ?? "",
|
|
99
|
+
hello: readStringField(body, 2) ?? ""
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (type === PACKET_TYPE_MESSAGE && bodyType === ANYBODY_FRIENDMSG) {
|
|
103
|
+
return {
|
|
104
|
+
type,
|
|
105
|
+
ext: readStringField(body, 0) ?? undefined,
|
|
106
|
+
data: readByteVectorField(body, 1) ?? new Uint8Array()
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`unsupported carrier packet type/body: ${type}/${bodyType}`);
|
|
110
|
+
}
|
|
111
|
+
function finishCarrierPacket(builder, type, bodyType, bodyOffset) {
|
|
112
|
+
builder.startObject(3);
|
|
113
|
+
builder.addFieldOffset(2, bodyOffset, 0);
|
|
114
|
+
builder.addFieldInt8(1, bodyType, 0);
|
|
115
|
+
builder.addFieldInt8(0, type, 0);
|
|
116
|
+
const packet = builder.endObject();
|
|
117
|
+
builder.finish(packet);
|
|
118
|
+
return builder.asUint8Array();
|
|
119
|
+
}
|
|
120
|
+
function readBoolField(table, field) {
|
|
121
|
+
const offset = table.bb.__offset(table.bb_pos, 4 + field * 2);
|
|
122
|
+
return offset ? table.bb.readUint8(table.bb_pos + offset) !== 0 : false;
|
|
123
|
+
}
|
|
124
|
+
function readUint8Field(table, field) {
|
|
125
|
+
const offset = table.bb.__offset(table.bb_pos, 4 + field * 2);
|
|
126
|
+
return offset ? table.bb.readUint8(table.bb_pos + offset) : 0;
|
|
127
|
+
}
|
|
128
|
+
function readStringField(table, field) {
|
|
129
|
+
const offset = table.bb.__offset(table.bb_pos, 4 + field * 2);
|
|
130
|
+
if (!offset) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
return table.bb.__string(table.bb_pos + offset, Encoding.UTF16_STRING);
|
|
134
|
+
}
|
|
135
|
+
function readTableField(table, field) {
|
|
136
|
+
const offset = table.bb.__offset(table.bb_pos, 4 + field * 2);
|
|
137
|
+
if (!offset) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const tableOffset = table.bb_pos + offset;
|
|
141
|
+
return {
|
|
142
|
+
bb: table.bb,
|
|
143
|
+
bb_pos: tableOffset + table.bb.readInt32(tableOffset)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function readByteVectorField(table, field) {
|
|
147
|
+
const offset = table.bb.__offset(table.bb_pos, 4 + field * 2);
|
|
148
|
+
if (!offset) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
const vector = table.bb.__vector(table.bb_pos + offset);
|
|
152
|
+
const length = table.bb.__vector_len(table.bb_pos + offset);
|
|
153
|
+
return table.bb.bytes().slice(vector, vector + length);
|
|
154
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import type { KeyPair } from "../crypto/keypair.js";
|
|
3
|
+
import type { NetworkNode } from "../types/peer.js";
|
|
4
|
+
export type TcpRelayPoolOptions = {
|
|
5
|
+
relays: NetworkNode[];
|
|
6
|
+
selfKeyPair: KeyPair;
|
|
7
|
+
maxConnections?: number;
|
|
8
|
+
/** Optional debug label inherited by underlying TcpRelayClients. */
|
|
9
|
+
label?: string;
|
|
10
|
+
};
|
|
11
|
+
export type TcpRelayPoolEvents = {
|
|
12
|
+
/** A friend is now online on at least one relay. */
|
|
13
|
+
friendOnline: (friendPublicKey: Uint8Array) => void;
|
|
14
|
+
/** A friend is no longer online on any relay. */
|
|
15
|
+
friendOffline: (friendPublicKey: Uint8Array) => void;
|
|
16
|
+
/** Inbound DATA forwarded by some relay. */
|
|
17
|
+
peerData: (friendPublicKey: Uint8Array, payload: Uint8Array) => void;
|
|
18
|
+
/** Inbound OOB_RECV (used for friend requests via TCP). */
|
|
19
|
+
oob: (senderPublicKey: Uint8Array, payload: Uint8Array) => void;
|
|
20
|
+
/** Number of currently-connected relays changed (for diagnostics). */
|
|
21
|
+
status: (connected: number, total: number) => void;
|
|
22
|
+
};
|
|
23
|
+
export declare class TcpRelayPool extends EventEmitter {
|
|
24
|
+
#private;
|
|
25
|
+
constructor(opts: TcpRelayPoolOptions);
|
|
26
|
+
/**
|
|
27
|
+
* Initialize the pool. Selects up to `maxConnections` relays from the
|
|
28
|
+
* supplied list (picks them in order; the operator can prioritize by
|
|
29
|
+
* sorting). Returns the count of initial connections successfully
|
|
30
|
+
* opened.
|
|
31
|
+
*/
|
|
32
|
+
start(): Promise<number>;
|
|
33
|
+
/** Tear down all relay connections. */
|
|
34
|
+
stop(): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Dynamically add a TCP relay to the pool — used when a friend's
|
|
37
|
+
* DHT-PK extras advertise a relay address we weren't already on.
|
|
38
|
+
* Without this, two peers on disjoint relay sets can never establish
|
|
39
|
+
* a TCP path even if both relays are reachable. Idempotent: skips if
|
|
40
|
+
* the host:port pair is already in the pool.
|
|
41
|
+
*
|
|
42
|
+
* Returns the index of the relay (existing or newly added).
|
|
43
|
+
*/
|
|
44
|
+
addRelay(node: NetworkNode): number;
|
|
45
|
+
/**
|
|
46
|
+
* Ask every connected relay to route packets for this friend. Idempotent;
|
|
47
|
+
* subsequent calls for an already-requested key are no-ops on each
|
|
48
|
+
* underlying client.
|
|
49
|
+
*/
|
|
50
|
+
requestRoute(friendPublicKey: Uint8Array): void;
|
|
51
|
+
/** True if at least one relay reports this friend currently online. */
|
|
52
|
+
isFriendOnline(friendPublicKey: Uint8Array): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* OOB_SEND — fire-and-forget delivery to any peer connected to any of
|
|
55
|
+
* our relays, bypassing routing. Used for the initial cookie request
|
|
56
|
+
* / cookie response / crypto handshake exchange before either side
|
|
57
|
+
* has issued ROUTING_REQUEST. Mirrors toxcore's tcp_send_oob_packet
|
|
58
|
+
* which net_crypto.c calls via send_oob_packet for the same purpose.
|
|
59
|
+
* Returns the count of relays the OOB packet was written to.
|
|
60
|
+
*/
|
|
61
|
+
sendOobToFriend(friendPublicKey: Uint8Array, payload: Uint8Array): number;
|
|
62
|
+
/**
|
|
63
|
+
* Forward a DATA payload to a friend through any relay where they're
|
|
64
|
+
* online. Returns the count of relays that accepted the write — 0 if
|
|
65
|
+
* the friend isn't online on any TCP relay.
|
|
66
|
+
*/
|
|
67
|
+
sendToFriend(friendPublicKey: Uint8Array, payload: Uint8Array): number;
|
|
68
|
+
/** Diagnostics. */
|
|
69
|
+
connectedCount(): number;
|
|
70
|
+
/**
|
|
71
|
+
* Snapshot of currently-connected relays for inclusion in DHT-PK
|
|
72
|
+
* announce extras. Mirrors toxcore's `tcp_copy_connected_relays`.
|
|
73
|
+
* Returns up to `max` entries, each with the relay's public key and
|
|
74
|
+
* its host:port — enough for the receiver to add the relay to its
|
|
75
|
+
* own pool via TcpRelayPool.addRelay and start routing through it.
|
|
76
|
+
*/
|
|
77
|
+
connectedRelays(max?: number): Array<{
|
|
78
|
+
host: string;
|
|
79
|
+
port: number;
|
|
80
|
+
serverPublicKey: Uint8Array;
|
|
81
|
+
}>;
|
|
82
|
+
on<E extends keyof TcpRelayPoolEvents>(event: E, listener: TcpRelayPoolEvents[E]): this;
|
|
83
|
+
once<E extends keyof TcpRelayPoolEvents>(event: E, listener: TcpRelayPoolEvents[E]): this;
|
|
84
|
+
off<E extends keyof TcpRelayPoolEvents>(event: E, listener: TcpRelayPoolEvents[E]): this;
|
|
85
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// Multi-relay TCP pool — owns a small number of TcpRelayClient
|
|
2
|
+
// connections to the operator-configured Carrier bootstrap nodes (each
|
|
3
|
+
// of which also runs a TCP relay server) and provides a unified
|
|
4
|
+
// per-friend send/receive surface to the Peer class.
|
|
5
|
+
//
|
|
6
|
+
// Design choices:
|
|
7
|
+
// - Up to N parallel relays (default 3, matches toxcore's
|
|
8
|
+
// TCP_CONNECTIONS_PER_FRIEND constant).
|
|
9
|
+
// - For every friend the Peer cares about, we issue ROUTING_REQUEST on
|
|
10
|
+
// every connected relay. The first relay where the friend is also
|
|
11
|
+
// routed produces a CONNECTION_NOTIFICATION. From then on we can
|
|
12
|
+
// forward DATA through that relay.
|
|
13
|
+
// - Inbound peerData from any relay is surfaced to a single event so
|
|
14
|
+
// the Peer doesn't need to know which relay delivered the bytes.
|
|
15
|
+
// - Outbound sendToFriend tries every relay that has the friend
|
|
16
|
+
// online, returning true if at least one accepted the write. iOS
|
|
17
|
+
// Beagle does the same — sends in parallel and lets net_crypto's
|
|
18
|
+
// packet number dedup handle duplicates.
|
|
19
|
+
// - Reconnect: if a relay socket drops, we re-open and re-issue route
|
|
20
|
+
// requests for all known friends. Bounded backoff so a permanently
|
|
21
|
+
// down relay doesn't burn CPU.
|
|
22
|
+
import { EventEmitter } from "node:events";
|
|
23
|
+
import { base58ToBytes } from "../utils/base58.js";
|
|
24
|
+
import { TcpRelayClient } from "./tcp-relay.js";
|
|
25
|
+
const KEY_SIZE = 32;
|
|
26
|
+
const MAX_RELAY_CONNECTIONS = 3;
|
|
27
|
+
const RECONNECT_BASE_MS = 5_000;
|
|
28
|
+
const RECONNECT_MAX_MS = 120_000;
|
|
29
|
+
export class TcpRelayPool extends EventEmitter {
|
|
30
|
+
#opts;
|
|
31
|
+
#relays = [];
|
|
32
|
+
#stopped = false;
|
|
33
|
+
// Friends we want routed on every connected relay. Re-asserted on
|
|
34
|
+
// reconnect.
|
|
35
|
+
#wantedFriends = new Map(); // hex -> raw pubkey
|
|
36
|
+
// Friend -> set of relay-indices currently reporting the friend online.
|
|
37
|
+
// We emit `friendOnline` on first transition to size 1, `friendOffline`
|
|
38
|
+
// on transition back to 0.
|
|
39
|
+
#onlineRelaysByFriend = new Map();
|
|
40
|
+
#debug = process.env.DECENT_DEBUG === "1";
|
|
41
|
+
constructor(opts) {
|
|
42
|
+
super();
|
|
43
|
+
this.#opts = opts;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Initialize the pool. Selects up to `maxConnections` relays from the
|
|
47
|
+
* supplied list (picks them in order; the operator can prioritize by
|
|
48
|
+
* sorting). Returns the count of initial connections successfully
|
|
49
|
+
* opened.
|
|
50
|
+
*/
|
|
51
|
+
async start() {
|
|
52
|
+
const max = Math.min(this.#opts.maxConnections ?? MAX_RELAY_CONNECTIONS, this.#opts.relays.length);
|
|
53
|
+
for (let i = 0; i < max; i++) {
|
|
54
|
+
const node = this.#opts.relays[i];
|
|
55
|
+
if (!node.pk)
|
|
56
|
+
continue;
|
|
57
|
+
let serverPk;
|
|
58
|
+
try {
|
|
59
|
+
serverPk = base58ToBytes(node.pk);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (serverPk.length !== KEY_SIZE)
|
|
65
|
+
continue;
|
|
66
|
+
this.#relays.push({ node, serverPublicKey: serverPk, reconnectAttempt: 0 });
|
|
67
|
+
}
|
|
68
|
+
// Open all in parallel; tolerate failures (each will reconnect).
|
|
69
|
+
const results = await Promise.allSettled(this.#relays.map((_, idx) => this.#openOne(idx)));
|
|
70
|
+
const opened = results.filter((r) => r.status === "fulfilled").length;
|
|
71
|
+
this.#emitStatus();
|
|
72
|
+
return opened;
|
|
73
|
+
}
|
|
74
|
+
/** Tear down all relay connections. */
|
|
75
|
+
async stop() {
|
|
76
|
+
this.#stopped = true;
|
|
77
|
+
for (const r of this.#relays) {
|
|
78
|
+
if (r.reconnectTimer)
|
|
79
|
+
clearTimeout(r.reconnectTimer);
|
|
80
|
+
r.reconnectTimer = undefined;
|
|
81
|
+
try {
|
|
82
|
+
r.client?.close("pool stop");
|
|
83
|
+
}
|
|
84
|
+
catch { /* ignore */ }
|
|
85
|
+
r.client = undefined;
|
|
86
|
+
}
|
|
87
|
+
this.#relays = [];
|
|
88
|
+
this.#wantedFriends.clear();
|
|
89
|
+
this.#onlineRelaysByFriend.clear();
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Dynamically add a TCP relay to the pool — used when a friend's
|
|
93
|
+
* DHT-PK extras advertise a relay address we weren't already on.
|
|
94
|
+
* Without this, two peers on disjoint relay sets can never establish
|
|
95
|
+
* a TCP path even if both relays are reachable. Idempotent: skips if
|
|
96
|
+
* the host:port pair is already in the pool.
|
|
97
|
+
*
|
|
98
|
+
* Returns the index of the relay (existing or newly added).
|
|
99
|
+
*/
|
|
100
|
+
addRelay(node) {
|
|
101
|
+
if (this.#stopped)
|
|
102
|
+
return -1;
|
|
103
|
+
if (!node.pk)
|
|
104
|
+
return -1;
|
|
105
|
+
let serverPk;
|
|
106
|
+
try {
|
|
107
|
+
serverPk = base58ToBytes(node.pk);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return -1;
|
|
111
|
+
}
|
|
112
|
+
if (serverPk.length !== KEY_SIZE)
|
|
113
|
+
return -1;
|
|
114
|
+
// Already in pool?
|
|
115
|
+
for (let i = 0; i < this.#relays.length; i++) {
|
|
116
|
+
const r = this.#relays[i];
|
|
117
|
+
if (r.node.host === node.host && r.node.port === node.port) {
|
|
118
|
+
return i;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const idx = this.#relays.length;
|
|
122
|
+
this.#relays.push({ node, serverPublicKey: serverPk, reconnectAttempt: 0 });
|
|
123
|
+
this.#log(`addRelay ${node.host}:${node.port} (idx=${idx})`);
|
|
124
|
+
void this.#openOne(idx).catch(() => { });
|
|
125
|
+
return idx;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Ask every connected relay to route packets for this friend. Idempotent;
|
|
129
|
+
* subsequent calls for an already-requested key are no-ops on each
|
|
130
|
+
* underlying client.
|
|
131
|
+
*/
|
|
132
|
+
requestRoute(friendPublicKey) {
|
|
133
|
+
if (friendPublicKey.length !== KEY_SIZE)
|
|
134
|
+
return;
|
|
135
|
+
const hex = Buffer.from(friendPublicKey).toString("hex");
|
|
136
|
+
this.#wantedFriends.set(hex, new Uint8Array(friendPublicKey));
|
|
137
|
+
for (const r of this.#relays) {
|
|
138
|
+
if (r.client?.state() === "connected") {
|
|
139
|
+
r.client.requestRoute(friendPublicKey);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** True if at least one relay reports this friend currently online. */
|
|
144
|
+
isFriendOnline(friendPublicKey) {
|
|
145
|
+
if (friendPublicKey.length !== KEY_SIZE)
|
|
146
|
+
return false;
|
|
147
|
+
const set = this.#onlineRelaysByFriend.get(Buffer.from(friendPublicKey).toString("hex"));
|
|
148
|
+
return !!(set && set.size > 0);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* OOB_SEND — fire-and-forget delivery to any peer connected to any of
|
|
152
|
+
* our relays, bypassing routing. Used for the initial cookie request
|
|
153
|
+
* / cookie response / crypto handshake exchange before either side
|
|
154
|
+
* has issued ROUTING_REQUEST. Mirrors toxcore's tcp_send_oob_packet
|
|
155
|
+
* which net_crypto.c calls via send_oob_packet for the same purpose.
|
|
156
|
+
* Returns the count of relays the OOB packet was written to.
|
|
157
|
+
*/
|
|
158
|
+
sendOobToFriend(friendPublicKey, payload) {
|
|
159
|
+
if (friendPublicKey.length !== KEY_SIZE)
|
|
160
|
+
return 0;
|
|
161
|
+
let sent = 0;
|
|
162
|
+
for (const r of this.#relays) {
|
|
163
|
+
if (r.client?.state() !== "connected")
|
|
164
|
+
continue;
|
|
165
|
+
if (r.client.sendOob(friendPublicKey, payload)) {
|
|
166
|
+
sent += 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return sent;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Forward a DATA payload to a friend through any relay where they're
|
|
173
|
+
* online. Returns the count of relays that accepted the write — 0 if
|
|
174
|
+
* the friend isn't online on any TCP relay.
|
|
175
|
+
*/
|
|
176
|
+
sendToFriend(friendPublicKey, payload) {
|
|
177
|
+
if (friendPublicKey.length !== KEY_SIZE)
|
|
178
|
+
return 0;
|
|
179
|
+
// Send via the FIRST connected relay where the friend is online; only
|
|
180
|
+
// fall over to the next relay if the send call returns false. Sending
|
|
181
|
+
// via every relay simultaneously (the previous behavior) caused each
|
|
182
|
+
// application-level message to be delivered N times to the receiver,
|
|
183
|
+
// where N = number of shared relays. The receiver's net_crypto layer
|
|
184
|
+
// does deduplicate by packet_number, but only after the duplicate has
|
|
185
|
+
// already been propagated through the message dispatcher — so peer
|
|
186
|
+
// callbacks (onText etc.) and any IP packet forwarding built on top
|
|
187
|
+
// get the duplicate too. With this change, redundancy still kicks in
|
|
188
|
+
// automatically when a relay rejects the write.
|
|
189
|
+
for (const r of this.#relays) {
|
|
190
|
+
if (r.client?.state() !== "connected")
|
|
191
|
+
continue;
|
|
192
|
+
if (!r.client.hasFriendOnline(friendPublicKey))
|
|
193
|
+
continue;
|
|
194
|
+
if (r.client.sendToFriend(friendPublicKey, payload)) {
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
/** Diagnostics. */
|
|
201
|
+
connectedCount() {
|
|
202
|
+
return this.#relays.filter((r) => r.client?.state() === "connected").length;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Snapshot of currently-connected relays for inclusion in DHT-PK
|
|
206
|
+
* announce extras. Mirrors toxcore's `tcp_copy_connected_relays`.
|
|
207
|
+
* Returns up to `max` entries, each with the relay's public key and
|
|
208
|
+
* its host:port — enough for the receiver to add the relay to its
|
|
209
|
+
* own pool via TcpRelayPool.addRelay and start routing through it.
|
|
210
|
+
*/
|
|
211
|
+
connectedRelays(max = 4) {
|
|
212
|
+
const out = [];
|
|
213
|
+
for (const r of this.#relays) {
|
|
214
|
+
if (out.length >= max)
|
|
215
|
+
break;
|
|
216
|
+
if (r.client?.state() === "connected") {
|
|
217
|
+
out.push({
|
|
218
|
+
host: r.node.host,
|
|
219
|
+
port: r.node.port,
|
|
220
|
+
serverPublicKey: r.serverPublicKey
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
async #openOne(idx) {
|
|
227
|
+
if (this.#stopped)
|
|
228
|
+
return;
|
|
229
|
+
const managed = this.#relays[idx];
|
|
230
|
+
if (!managed)
|
|
231
|
+
return;
|
|
232
|
+
if (managed.client)
|
|
233
|
+
return; // already trying
|
|
234
|
+
const client = new TcpRelayClient({
|
|
235
|
+
host: managed.node.host,
|
|
236
|
+
port: managed.node.port,
|
|
237
|
+
serverPublicKey: managed.serverPublicKey,
|
|
238
|
+
selfKeyPair: this.#opts.selfKeyPair,
|
|
239
|
+
label: this.#opts.label
|
|
240
|
+
? `${this.#opts.label}#${idx}`
|
|
241
|
+
: `relay#${idx}`
|
|
242
|
+
});
|
|
243
|
+
managed.client = client;
|
|
244
|
+
client.on("open", () => {
|
|
245
|
+
managed.reconnectAttempt = 0;
|
|
246
|
+
this.#log(`relay ${idx} ${managed.node.host}:${managed.node.port} open`);
|
|
247
|
+
// (Re-)issue routes for all wanted friends.
|
|
248
|
+
for (const friend of this.#wantedFriends.values()) {
|
|
249
|
+
client.requestRoute(friend);
|
|
250
|
+
}
|
|
251
|
+
this.#emitStatus();
|
|
252
|
+
});
|
|
253
|
+
client.on("close", (reason) => {
|
|
254
|
+
this.#log(`relay ${idx} ${managed.node.host}:${managed.node.port} closed: ${reason}`);
|
|
255
|
+
// Demote any "online" markers held by this relay for our friends.
|
|
256
|
+
for (const [friendHex, set] of this.#onlineRelaysByFriend) {
|
|
257
|
+
if (set.delete(idx) && set.size === 0) {
|
|
258
|
+
const raw = this.#wantedFriends.get(friendHex);
|
|
259
|
+
if (raw)
|
|
260
|
+
this.emit("friendOffline", raw);
|
|
261
|
+
this.#onlineRelaysByFriend.delete(friendHex);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
managed.client = undefined;
|
|
265
|
+
this.#emitStatus();
|
|
266
|
+
this.#scheduleReconnect(idx);
|
|
267
|
+
});
|
|
268
|
+
client.on("friendOnline", (friendKey) => {
|
|
269
|
+
const hex = Buffer.from(friendKey).toString("hex");
|
|
270
|
+
let set = this.#onlineRelaysByFriend.get(hex);
|
|
271
|
+
if (!set) {
|
|
272
|
+
set = new Set();
|
|
273
|
+
this.#onlineRelaysByFriend.set(hex, set);
|
|
274
|
+
}
|
|
275
|
+
const wasEmpty = set.size === 0;
|
|
276
|
+
set.add(idx);
|
|
277
|
+
if (wasEmpty) {
|
|
278
|
+
this.emit("friendOnline", new Uint8Array(friendKey));
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
client.on("friendOffline", (friendKey) => {
|
|
282
|
+
const hex = Buffer.from(friendKey).toString("hex");
|
|
283
|
+
const set = this.#onlineRelaysByFriend.get(hex);
|
|
284
|
+
if (!set)
|
|
285
|
+
return;
|
|
286
|
+
if (set.delete(idx) && set.size === 0) {
|
|
287
|
+
this.#onlineRelaysByFriend.delete(hex);
|
|
288
|
+
this.emit("friendOffline", new Uint8Array(friendKey));
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
client.on("peerData", (friendKey, payload) => {
|
|
292
|
+
this.emit("peerData", new Uint8Array(friendKey), payload);
|
|
293
|
+
});
|
|
294
|
+
client.on("oob", (senderKey, payload) => {
|
|
295
|
+
this.emit("oob", new Uint8Array(senderKey), payload);
|
|
296
|
+
});
|
|
297
|
+
try {
|
|
298
|
+
await client.connect(10_000);
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
this.#log(`relay ${idx} ${managed.node.host}:${managed.node.port} connect failed: ${err.message}`);
|
|
302
|
+
managed.client = undefined;
|
|
303
|
+
this.#scheduleReconnect(idx);
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
#scheduleReconnect(idx) {
|
|
308
|
+
if (this.#stopped)
|
|
309
|
+
return;
|
|
310
|
+
const managed = this.#relays[idx];
|
|
311
|
+
if (!managed)
|
|
312
|
+
return;
|
|
313
|
+
if (managed.reconnectTimer)
|
|
314
|
+
return;
|
|
315
|
+
managed.reconnectAttempt = Math.min(managed.reconnectAttempt + 1, 8);
|
|
316
|
+
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * Math.pow(1.5, managed.reconnectAttempt - 1));
|
|
317
|
+
managed.reconnectTimer = setTimeout(() => {
|
|
318
|
+
managed.reconnectTimer = undefined;
|
|
319
|
+
void this.#openOne(idx).catch(() => { });
|
|
320
|
+
}, delay);
|
|
321
|
+
managed.reconnectTimer.unref?.();
|
|
322
|
+
this.#log(`relay ${idx} ${managed.node.host}:${managed.node.port} reconnect in ${Math.round(delay)}ms (attempt ${managed.reconnectAttempt})`);
|
|
323
|
+
}
|
|
324
|
+
#emitStatus() {
|
|
325
|
+
this.emit("status", this.connectedCount(), this.#relays.length);
|
|
326
|
+
}
|
|
327
|
+
#log(message) {
|
|
328
|
+
if (!this.#debug)
|
|
329
|
+
return;
|
|
330
|
+
const label = this.#opts.label ? `:${this.#opts.label}` : "";
|
|
331
|
+
console.log(`[peer-debug:tcp-pool${label}] ${message}`);
|
|
332
|
+
}
|
|
333
|
+
on(event, listener) {
|
|
334
|
+
return super.on(event, listener);
|
|
335
|
+
}
|
|
336
|
+
once(event, listener) {
|
|
337
|
+
return super.once(event, listener);
|
|
338
|
+
}
|
|
339
|
+
off(event, listener) {
|
|
340
|
+
return super.off(event, listener);
|
|
341
|
+
}
|
|
342
|
+
}
|