gnutella 1.0.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.
Files changed (45) hide show
  1. package/CLI.md +189 -0
  2. package/DEVELOPER.md +193 -0
  3. package/LICENSE +674 -0
  4. package/QUICKSTART.md +133 -0
  5. package/README.md +74 -0
  6. package/bin/gnutella.ts +15 -0
  7. package/gnutella.json.example +18 -0
  8. package/package.json +72 -0
  9. package/src/cli.ts +692 -0
  10. package/src/cli_shared.ts +359 -0
  11. package/src/const.ts +138 -0
  12. package/src/gwebcache/bootstrap.ts +491 -0
  13. package/src/gwebcache/response.ts +391 -0
  14. package/src/gwebcache/shared.ts +116 -0
  15. package/src/gwebcache/types.ts +187 -0
  16. package/src/gwebcache_client.ts +13 -0
  17. package/src/protocol/browse_host.ts +552 -0
  18. package/src/protocol/client_blocking.ts +29 -0
  19. package/src/protocol/codec.ts +715 -0
  20. package/src/protocol/content_urn.ts +170 -0
  21. package/src/protocol/core_utils.ts +43 -0
  22. package/src/protocol/file_server.ts +245 -0
  23. package/src/protocol/ggep.ts +168 -0
  24. package/src/protocol/handshake.ts +199 -0
  25. package/src/protocol/http_download_reader.ts +112 -0
  26. package/src/protocol/magnet.ts +176 -0
  27. package/src/protocol/node.ts +416 -0
  28. package/src/protocol/node_handshake.ts +992 -0
  29. package/src/protocol/node_lifecycle.ts +210 -0
  30. package/src/protocol/node_protocol_runtime.ts +949 -0
  31. package/src/protocol/node_qrp_runtime.ts +97 -0
  32. package/src/protocol/node_query_routing.ts +208 -0
  33. package/src/protocol/node_state.ts +745 -0
  34. package/src/protocol/node_tls.ts +257 -0
  35. package/src/protocol/node_topology.ts +141 -0
  36. package/src/protocol/node_transfer.ts +455 -0
  37. package/src/protocol/node_types.ts +106 -0
  38. package/src/protocol/peer_state.ts +675 -0
  39. package/src/protocol/qrp.ts +549 -0
  40. package/src/protocol/query_search.ts +29 -0
  41. package/src/protocol/share_index.ts +131 -0
  42. package/src/protocol/share_library.ts +246 -0
  43. package/src/protocol.ts +36 -0
  44. package/src/shared.ts +236 -0
  45. package/src/types.ts +452 -0
@@ -0,0 +1,552 @@
1
+ import net from "node:net";
2
+ import zlib from "node:zlib";
3
+
4
+ import { errMsg, parsePeer } from "../shared";
5
+ import { HEADER_LEN, LOCAL_ROUTE, TYPE } from "../const";
6
+ import { buildHeader, encodeQueryHit, parseHeader } from "./codec";
7
+ import {
8
+ findHeaderEnd,
9
+ hasToken,
10
+ parseHttpHeaders,
11
+ socketCanEnd,
12
+ } from "./handshake";
13
+ import type { GnutellaServent } from "./node";
14
+ import type { ExistingGetRequest, Peer } from "./node_types";
15
+ import { peerBrowseTarget } from "./node_state";
16
+
17
+ const BROWSE_HOST_ACCEPT = "application/x-gnutella-packets";
18
+ const BROWSE_HOST_DESCRIPTOR_ID = Buffer.alloc(16, 0);
19
+ const BROWSE_HOST_BATCH_SIZE = 16;
20
+ const BROWSE_HOST_TIMEOUT_MESSAGE = "browse host timeout";
21
+
22
+ type BrowseTarget = {
23
+ peer: Peer;
24
+ host: string;
25
+ port: number;
26
+ };
27
+
28
+ function acceptsBrowseHostQhits(request: ExistingGetRequest): boolean {
29
+ const accept = request.headers["accept"];
30
+ if (!accept) return false;
31
+ return accept.split(",").some((part) => {
32
+ const mediaType = part.split(";", 1)[0]?.trim().toLowerCase();
33
+ return mediaType === BROWSE_HOST_ACCEPT;
34
+ });
35
+ }
36
+
37
+ function buildBrowseHostResponse(
38
+ statusLine: string,
39
+ extraHeaders: string[] = [],
40
+ ): string {
41
+ return [
42
+ statusLine,
43
+ "Server: Gnutella",
44
+ ...extraHeaders,
45
+ "X-Features: browse/1.0",
46
+ "Connection: close",
47
+ "",
48
+ "",
49
+ ].join("\r\n");
50
+ }
51
+
52
+ function mediaTypeOf(value: string | undefined): string | undefined {
53
+ return value?.split(";", 1)[0]?.trim().toLowerCase();
54
+ }
55
+
56
+ function parseContentLength(
57
+ headers: Record<string, string>,
58
+ ): number | undefined {
59
+ const raw = headers["content-length"];
60
+ if (!raw) return undefined;
61
+ const parsed = Number(raw);
62
+ if (!Number.isFinite(parsed) || parsed < 0)
63
+ throw new Error(`invalid Content-Length ${JSON.stringify(raw)}`);
64
+ return parsed;
65
+ }
66
+
67
+ function peerAddressMatches(
68
+ peerSpec: string | undefined,
69
+ host: string,
70
+ port: number,
71
+ ): boolean {
72
+ const parsed = parsePeer(peerSpec || "");
73
+ return !!parsed && parsed.host === host && parsed.port === port;
74
+ }
75
+
76
+ function peerMatchesBrowseAddress(
77
+ peer: Peer,
78
+ host: string,
79
+ port: number,
80
+ ): boolean {
81
+ const fromListenIp = peer.capabilities.listenIp;
82
+ if (
83
+ fromListenIp &&
84
+ fromListenIp.host === host &&
85
+ fromListenIp.port === port
86
+ ) {
87
+ return true;
88
+ }
89
+
90
+ return (
91
+ peerAddressMatches(peer.dialTarget, host, port) ||
92
+ peerAddressMatches(peer.remoteLabel, host, port)
93
+ );
94
+ }
95
+
96
+ function syntheticBrowsePeer(host: string, port: number): Peer {
97
+ return {
98
+ key: `${host}:${port}`,
99
+ socket: new net.Socket(),
100
+ buf: Buffer.alloc(0),
101
+ outbound: true,
102
+ remoteLabel: `${host}:${port}`,
103
+ dialTarget: `${host}:${port}`,
104
+ role: "leaf",
105
+ capabilities: {
106
+ version: "0.6",
107
+ headers: {},
108
+ supportsGgep: false,
109
+ supportsPongCaching: false,
110
+ supportsBye: false,
111
+ supportsCompression: false,
112
+ supportsTls: false,
113
+ compressIn: false,
114
+ compressOut: false,
115
+ isUltrapeer: false,
116
+ ultrapeerNeeded: false,
117
+ isCrawler: false,
118
+ },
119
+ remoteQrp: {
120
+ resetSeen: false,
121
+ tableSize: 0,
122
+ infinity: 0,
123
+ entryBits: 0,
124
+ table: null,
125
+ seqSize: 0,
126
+ compressor: 0,
127
+ parts: new Map<number, Buffer>(),
128
+ },
129
+ lastPingAt: 0,
130
+ connectedAt: 0,
131
+ };
132
+ }
133
+
134
+ function resolvePeerBrowseTarget(
135
+ node: GnutellaServent,
136
+ peerKey: string,
137
+ ): BrowseTarget | undefined {
138
+ const peer = node.peers.get(peerKey);
139
+ if (!peer) return undefined;
140
+ const target = peerBrowseTarget(node, peer);
141
+ const parsed = parsePeer(target || "");
142
+ if (!parsed)
143
+ throw new Error(`peer ${peerKey} has no browseable ip:port`);
144
+ return { peer, host: parsed.host, port: parsed.port };
145
+ }
146
+
147
+ function resolveBrowseTarget(
148
+ node: GnutellaServent,
149
+ target: string,
150
+ ): BrowseTarget {
151
+ const byPeer = resolvePeerBrowseTarget(node, target);
152
+ if (byPeer) return byPeer;
153
+
154
+ const parsed = parsePeer(target);
155
+ if (!parsed) throw new Error(`no such peer ${target}`);
156
+ if (node.isSelfPeer(parsed.host, parsed.port)) {
157
+ throw new Error("cannot browse self");
158
+ }
159
+
160
+ const existingPeer = [...node.peers.values()].find((peer) =>
161
+ peerMatchesBrowseAddress(peer, parsed.host, parsed.port),
162
+ );
163
+ return {
164
+ peer: existingPeer || syntheticBrowsePeer(parsed.host, parsed.port),
165
+ host: parsed.host,
166
+ port: parsed.port,
167
+ };
168
+ }
169
+
170
+ function buildBrowseHostRequest(
171
+ node: GnutellaServent,
172
+ host: string,
173
+ port: number,
174
+ ): string {
175
+ return [
176
+ "GET / HTTP/1.1",
177
+ `Host: ${host}:${port}`,
178
+ `User-Agent: ${node.config().userAgent}`,
179
+ `Accept: ${BROWSE_HOST_ACCEPT}`,
180
+ "Accept-Encoding: deflate",
181
+ "Connection: close",
182
+ "",
183
+ "",
184
+ ].join("\r\n");
185
+ }
186
+
187
+ function parseChunkSizeLine(
188
+ body: Buffer,
189
+ offset: number,
190
+ ): {
191
+ size: number;
192
+ nextOffset: number;
193
+ } {
194
+ const lineEnd = body.indexOf(Buffer.from("\r\n"), offset);
195
+ if (lineEnd === -1) throw new Error("invalid chunked browse-host body");
196
+ const rawSize = body
197
+ .subarray(offset, lineEnd)
198
+ .toString("latin1")
199
+ .split(";", 1)[0]
200
+ ?.trim();
201
+ const size = Number.parseInt(rawSize || "", 16);
202
+ if (!Number.isFinite(size) || size < 0)
203
+ throw new Error(
204
+ `invalid browse-host chunk size ${JSON.stringify(rawSize)}`,
205
+ );
206
+ return { size, nextOffset: lineEnd + 2 };
207
+ }
208
+
209
+ function assertChunkTerminator(body: Buffer, offset: number): void {
210
+ if (body.toString("latin1", offset, offset + 2) !== "\r\n") {
211
+ throw new Error("invalid browse-host chunk terminator");
212
+ }
213
+ }
214
+
215
+ function decodeChunkedBody(body: Buffer): Buffer {
216
+ const parts: Buffer[] = [];
217
+ let offset = 0;
218
+
219
+ while (offset < body.length) {
220
+ const parsed = parseChunkSizeLine(body, offset);
221
+ const size = parsed.size;
222
+ offset = parsed.nextOffset;
223
+ if (size === 0) return Buffer.concat(parts);
224
+ if (offset + size + 2 > body.length)
225
+ throw new Error("truncated browse-host chunk");
226
+ parts.push(body.subarray(offset, offset + size));
227
+ offset += size;
228
+ assertChunkTerminator(body, offset);
229
+ offset += 2;
230
+ }
231
+
232
+ throw new Error("truncated browse-host chunked stream");
233
+ }
234
+
235
+ function decodeBrowseHostBody(
236
+ headers: Record<string, string>,
237
+ body: Buffer,
238
+ ): Buffer {
239
+ const transferEncoding = headers["transfer-encoding"];
240
+ let decoded = body;
241
+ if (transferEncoding && !hasToken(transferEncoding, "chunked")) {
242
+ throw new Error(
243
+ `unsupported Transfer-Encoding ${JSON.stringify(transferEncoding)}`,
244
+ );
245
+ }
246
+ if (hasToken(transferEncoding, "chunked")) {
247
+ decoded = decodeChunkedBody(decoded);
248
+ }
249
+
250
+ const contentEncoding = headers["content-encoding"];
251
+ if (!contentEncoding) return decoded;
252
+ if (!hasToken(contentEncoding, "deflate")) {
253
+ throw new Error(
254
+ `unsupported Content-Encoding ${JSON.stringify(contentEncoding)}`,
255
+ );
256
+ }
257
+ return zlib.inflateSync(decoded);
258
+ }
259
+
260
+ async function readBrowseHostHttpResponse(
261
+ socket: net.Socket,
262
+ ): Promise<{ head: string; body: Buffer }> {
263
+ return await new Promise((resolve, reject) => {
264
+ let done = false;
265
+ let buf = Buffer.alloc(0);
266
+ let headerEnd = -1;
267
+ let expectedBodyBytes: number | undefined;
268
+
269
+ const cleanup = () => {
270
+ socket.off("data", onData);
271
+ socket.off("end", onEnd);
272
+ socket.off("close", onClose);
273
+ socket.off("error", onError);
274
+ };
275
+
276
+ const fail = (error: unknown) => {
277
+ if (done) return;
278
+ done = true;
279
+ cleanup();
280
+ reject(error instanceof Error ? error : new Error(errMsg(error)));
281
+ };
282
+
283
+ const finish = () => {
284
+ if (done) return;
285
+ done = true;
286
+ cleanup();
287
+ if (headerEnd === -1) {
288
+ reject(new Error("missing browse-host HTTP response headers"));
289
+ return;
290
+ }
291
+ const head = buf.subarray(0, headerEnd).toString("latin1");
292
+ const body = buf.subarray(headerEnd);
293
+ if (expectedBodyBytes != null && body.length < expectedBodyBytes) {
294
+ reject(new Error("incomplete browse-host HTTP response body"));
295
+ return;
296
+ }
297
+ resolve({
298
+ head,
299
+ body:
300
+ expectedBodyBytes == null
301
+ ? body
302
+ : body.subarray(0, expectedBodyBytes),
303
+ });
304
+ };
305
+
306
+ const maybeParseHeaders = () => {
307
+ if (headerEnd !== -1) return;
308
+ const cut = findHeaderEnd(buf.toString("latin1"));
309
+ if (cut === -1) return;
310
+ headerEnd = cut;
311
+ expectedBodyBytes = parseContentLength(
312
+ parseHttpHeaders(buf.subarray(0, cut).toString("latin1")),
313
+ );
314
+ };
315
+
316
+ const onData = (chunk: string | Buffer) => {
317
+ if (done) return;
318
+ buf = Buffer.concat([buf, Buffer.from(chunk)]);
319
+ maybeParseHeaders();
320
+ if (
321
+ headerEnd !== -1 &&
322
+ expectedBodyBytes != null &&
323
+ buf.length - headerEnd >= expectedBodyBytes
324
+ ) {
325
+ finish();
326
+ }
327
+ };
328
+
329
+ const onEnd = () => finish();
330
+ const onClose = () => {
331
+ if (done) return;
332
+ if (
333
+ headerEnd !== -1 &&
334
+ (expectedBodyBytes == null ||
335
+ buf.length - headerEnd >= expectedBodyBytes)
336
+ ) {
337
+ finish();
338
+ return;
339
+ }
340
+ fail(
341
+ new Error(
342
+ "browse-host connection closed before response completed",
343
+ ),
344
+ );
345
+ };
346
+ const onError = (error: unknown) => fail(error);
347
+
348
+ socket.on("data", onData);
349
+ socket.on("end", onEnd);
350
+ socket.on("close", onClose);
351
+ socket.on("error", onError);
352
+ });
353
+ }
354
+
355
+ function validateBrowseHostResponse(head: string): Record<string, string> {
356
+ const first = head.replace(/\r\n/g, "\n").split("\n", 1)[0] || "";
357
+ const status = Number(/^HTTP\/\d+\.\d+\s+(\d+)/i.exec(first)?.[1]);
358
+ if (!Number.isFinite(status))
359
+ throw new Error("invalid browse-host HTTP response");
360
+ if (status === 406) {
361
+ throw new Error("browse host rejected application/x-gnutella-packets");
362
+ }
363
+ if (status !== 200) {
364
+ throw new Error(`browse host failed with ${first}`);
365
+ }
366
+
367
+ const headers = parseHttpHeaders(head);
368
+ if (mediaTypeOf(headers["content-type"]) !== BROWSE_HOST_ACCEPT) {
369
+ throw new Error(
370
+ `unexpected browse-host content type ${JSON.stringify(headers["content-type"] || "")}`,
371
+ );
372
+ }
373
+ return headers;
374
+ }
375
+
376
+ async function connectBrowseHostSocket(
377
+ node: GnutellaServent,
378
+ socket: net.Socket,
379
+ host: string,
380
+ port: number,
381
+ ): Promise<void> {
382
+ await new Promise<void>((resolve, reject) => {
383
+ const onError = (error: unknown) => {
384
+ socket.removeListener("connect", onConnect);
385
+ reject(error instanceof Error ? error : new Error(errMsg(error)));
386
+ };
387
+ const onConnect = () => {
388
+ socket.removeListener("error", onError);
389
+ socket.write(buildBrowseHostRequest(node, host, port));
390
+ resolve();
391
+ };
392
+ socket.once("error", onError);
393
+ socket.once("connect", onConnect);
394
+ });
395
+ }
396
+
397
+ function ingestBrowseHostBody(
398
+ node: GnutellaServent,
399
+ peer: Peer,
400
+ body: Buffer,
401
+ ): number {
402
+ const browseDescriptorId = node.randomId16();
403
+ const descriptorIdHex = browseDescriptorId.toString("hex");
404
+ const before = node.lastResults.length;
405
+
406
+ node.queryRoutes.set(descriptorIdHex, LOCAL_ROUTE);
407
+ try {
408
+ for (let offset = 0; offset < body.length; ) {
409
+ if (offset + HEADER_LEN > body.length) {
410
+ throw new Error("truncated browse-host packet header");
411
+ }
412
+ const packetHeader = parseHeader(
413
+ body.subarray(offset, offset + HEADER_LEN),
414
+ );
415
+ offset += HEADER_LEN;
416
+ if (offset + packetHeader.payloadLength > body.length) {
417
+ throw new Error("truncated browse-host packet payload");
418
+ }
419
+ if (packetHeader.payloadType !== TYPE.QUERY_HIT) {
420
+ throw new Error(
421
+ `unexpected browse-host packet type 0x${packetHeader.payloadType.toString(16)}`,
422
+ );
423
+ }
424
+ const payload = body.subarray(
425
+ offset,
426
+ offset + packetHeader.payloadLength,
427
+ );
428
+ offset += packetHeader.payloadLength;
429
+ node.onQueryHit(
430
+ peer,
431
+ {
432
+ descriptorId: browseDescriptorId,
433
+ descriptorIdHex,
434
+ payloadType: packetHeader.payloadType,
435
+ ttl: packetHeader.ttl,
436
+ hops: packetHeader.hops,
437
+ },
438
+ payload,
439
+ );
440
+ }
441
+ } finally {
442
+ node.queryRoutes.delete(descriptorIdHex);
443
+ }
444
+
445
+ return node.lastResults.length - before;
446
+ }
447
+
448
+ export async function browsePeer(
449
+ node: GnutellaServent,
450
+ targetSpec: string,
451
+ ): Promise<number> {
452
+ const target = resolveBrowseTarget(node, targetSpec);
453
+ const socket = node.createConnection({
454
+ host: target.host,
455
+ port: target.port,
456
+ });
457
+ socket.setNoDelay(true);
458
+ socket.setTimeout(node.config().downloadTimeoutMs, () =>
459
+ socket.destroy(new Error(BROWSE_HOST_TIMEOUT_MESSAGE)),
460
+ );
461
+ const responsePromise = readBrowseHostHttpResponse(socket);
462
+
463
+ try {
464
+ await connectBrowseHostSocket(node, socket, target.host, target.port);
465
+ const { head, body } = await responsePromise;
466
+ const headers = validateBrowseHostResponse(head);
467
+
468
+ return ingestBrowseHostBody(
469
+ node,
470
+ target.peer,
471
+ decodeBrowseHostBody(headers, body),
472
+ );
473
+ } catch (error) {
474
+ await responsePromise.catch(() => undefined);
475
+ throw error;
476
+ } finally {
477
+ if (socketCanEnd(socket)) socket.end();
478
+ }
479
+ }
480
+
481
+ function buildBrowseHostBody(node: GnutellaServent): Buffer {
482
+ const packets: Buffer[] = [];
483
+ for (
484
+ let offset = 0;
485
+ offset < node.shares.length;
486
+ offset += BROWSE_HOST_BATCH_SIZE
487
+ ) {
488
+ const batch = node.shares.slice(
489
+ offset,
490
+ offset + BROWSE_HOST_BATCH_SIZE,
491
+ );
492
+ const payload = encodeQueryHit(
493
+ node.currentAdvertisedPort(),
494
+ node.currentAdvertisedHost(),
495
+ node.config().advertisedSpeedKBps,
496
+ batch,
497
+ node.serventId,
498
+ {
499
+ vendorCode: node.config().vendorCode,
500
+ busy: false,
501
+ haveUploaded: false,
502
+ measuredSpeed: true,
503
+ push: false,
504
+ ggepHashes: !!node.config().enableGgep,
505
+ browseHost: !!node.config().enableGgep,
506
+ },
507
+ );
508
+ packets.push(
509
+ buildHeader(
510
+ BROWSE_HOST_DESCRIPTOR_ID,
511
+ TYPE.QUERY_HIT,
512
+ 0,
513
+ 0,
514
+ payload,
515
+ ),
516
+ );
517
+ }
518
+ return Buffer.concat(packets);
519
+ }
520
+
521
+ export function isBrowseHostGetRequest(head: string): boolean {
522
+ const first = head.replace(/\r\n/g, "\n").split("\n", 1)[0] || "";
523
+ return /^(GET|HEAD)\s+\/\s+HTTP\/(\d+\.\d+)$/i.test(first);
524
+ }
525
+
526
+ export async function handleBrowseHostGet(
527
+ node: GnutellaServent,
528
+ socket: net.Socket,
529
+ head: string,
530
+ ): Promise<boolean> {
531
+ const request = node.parseExistingGetRequest(head);
532
+ if (!acceptsBrowseHostQhits(request)) {
533
+ socket.write(
534
+ buildBrowseHostResponse("HTTP/1.1 406 Not Acceptable", [
535
+ "Content-Length: 0",
536
+ ]),
537
+ );
538
+ if (socketCanEnd(socket)) socket.end();
539
+ return false;
540
+ }
541
+
542
+ socket.write(
543
+ buildBrowseHostResponse(`${request.responseVersion} 200 OK`, [
544
+ `Content-Type: ${BROWSE_HOST_ACCEPT}`,
545
+ ]),
546
+ );
547
+ if (request.method !== "HEAD") {
548
+ socket.write(buildBrowseHostBody(node));
549
+ }
550
+ if (socketCanEnd(socket)) socket.end();
551
+ return false;
552
+ }
@@ -0,0 +1,29 @@
1
+ import { normalizeIpv4 } from "../shared";
2
+
3
+ function normalizeClientSignature(
4
+ value: string | undefined,
5
+ ): string | undefined {
6
+ const signature = String(value || "").trim();
7
+ return signature || undefined;
8
+ }
9
+
10
+ export function blockedClientSignature(
11
+ headers: Record<string, string>,
12
+ ): string | undefined {
13
+ for (const key of ["user-agent", "server"] as const) {
14
+ const signature = normalizeClientSignature(headers[key]);
15
+ if (!signature) continue;
16
+ if (signature.toLowerCase().includes("foxy")) return signature;
17
+ }
18
+ return undefined;
19
+ }
20
+
21
+ export function blockedClientMessage(
22
+ signature: string,
23
+ remoteHost?: string,
24
+ ): string {
25
+ const ip = normalizeIpv4(remoteHost);
26
+ const parts = [`signature=${JSON.stringify(signature)}`];
27
+ if (ip) parts.push(`ip=${ip}`);
28
+ return `blocked client ${parts.join(" ")}`;
29
+ }