[TS] Add a socket handling implementation that uses Node.JS's `net`
authorMatt Corallo <git@bluematt.me>
Fri, 22 Jul 2022 21:13:22 +0000 (21:13 +0000)
committerMatt Corallo <git@bluematt.me>
Fri, 22 Jul 2022 23:45:01 +0000 (23:45 +0000)
.github/workflows/build.yml
README.md
genbindings.sh
node-net/README.md [new file with mode: 0644]
node-net/net.mts [new file with mode: 0644]
node-net/package.json [new file with mode: 0644]
node-net/test/test.mts [new file with mode: 0644]
node-net/tsconfig.json [new file with mode: 0644]

index 72d97454402b42a9916ff8bc85fc7d3dacbcdb4a..638bab960e133b14d28e90623ba9f0b54c44dcf4 100644 (file)
@@ -96,6 +96,7 @@ jobs:
       - name: Check latest TS files are in git
         run: |
           git checkout ts/package.json
+          git checkout node-net/package.json
           git diff --exit-code
 
   java_bindings:
index 4c937681b77b79dd713e8a79d0227934fd913377..7affd5307490395cef8136c3511900f6f23fc576 100644 (file)
--- a/README.md
+++ b/README.md
@@ -62,6 +62,11 @@ The TypeScript bindings require modern web standards, including support for `Fin
 and `WeakRef` (Chrome 84, Firefox 79, Safari 14.1/iOS 14.5 and Node 14.6) and WASM BigInt support
 (Chrome 85, Firefox 78, Safari 14.1/iOS 14.5, and Node 15.0).
 
+For users of Node.JS environments you may wish to use the `lightningdevkit-node-net` package as
+well to implement the required network handling to bridge the `lightningdevkit` package's
+`SocketDescriptor` interface to Node.JS TCP Sockets. For those wishing to run a lightning node in
+the browser you will need to provide your own bridge from `SocketDescriptor` to a WebSocket proxy.
+
 ## General
 
 The only known issue resulting in a use-after-free bug requires custom a custom ChannelKeys instance
index 504fbc70e1937ae1699b12c46d1ebad9a1fb7ab0..8034337dc22b4ddd25194e063eec30e1f91d0fcf 100755 (executable)
@@ -190,6 +190,8 @@ else
        fi
        rm -f ts/bindings.c
        sed -i 's/^  "version": .*/  "version": "'${LDK_GARBAGECOLLECTED_GIT_OVERRIDE:1:100}'",/g' ts/package.json
+       sed -i 's/^  "version": .*/  "version": "'${LDK_GARBAGECOLLECTED_GIT_OVERRIDE:1:100}'",/g' node-net/package.json
+       sed -i 's/^    "lightningdevkit": .*/    "lightningdevkit": "'${LDK_GARBAGECOLLECTED_GIT_OVERRIDE:1:100}'"/g' node-net/package.json
        if [ "$3" = "true" ]; then
                echo "#define LDK_DEBUG_BUILD" > ts/bindings.c
        elif [ "$3" = "leaks" ]; then
@@ -224,11 +226,16 @@ else
                tsc --types node --typeRoots .
                cp ../$WASM_FILE liblightningjs.wasm
                cp ../README.md README.md
+               cd ../node-net
+               tsc --types node --typeRoots .
                echo Ready to publish!
                if [ -x "$(which node)" ]; then
                        NODE_V="$(node --version)"
                        if [ "${NODE_V:1:2}" -gt 14 ]; then
+                               cd ../ts
                                node test/node.mjs
+                               cd ../node-net
+                               node test/test.mjs
                        fi
                fi
        fi
diff --git a/node-net/README.md b/node-net/README.md
new file mode 100644 (file)
index 0000000..28c45f8
--- /dev/null
@@ -0,0 +1,5 @@
+LDK Node.JS TypeScript Network Implementation
+=============================================
+
+This module bridges the LDK `SocketDescriptor` and `PeerManager` interfaces to Node.JS's `net`
+TCP sockets. See the `lightningdevkit` module for more info.
diff --git a/node-net/net.mts b/node-net/net.mts
new file mode 100644 (file)
index 0000000..58da387
--- /dev/null
@@ -0,0 +1,177 @@
+import * as ldk from "lightningdevkit";
+import * as net from "net";
+
+/**
+ * Handles TCP connections using Node.JS's 'net' module given an `ldk.PeerManager`.
+ */
+export class NodeLDKNet {
+       private ping_timer;
+       private servers: net.Server[];
+       public constructor(public peer_manager: ldk.PeerManager) {
+               this.ping_timer = setInterval(function() {
+                       peer_manager.timer_tick_occurred();
+                       peer_manager.process_events();
+               }, 10_000);
+               this.servers = [];
+       }
+
+       /**
+        * Disconnects all connections and releases all resources for this net handler.
+        */
+       public stop() {
+               clearInterval(this.ping_timer);
+               for (const server of this.servers) {
+                       server.close();
+               }
+               this.peer_manager.disconnect_all_peers();
+       }
+
+       /**
+        * Processes any pending events for the PeerManager, sending queued messages.
+        * You should call this (or peer_manager.process_events()) any time you take an action which
+        * is likely to generate messages to send (eg send a payment, processing payment forwards,
+        * etc).
+        */
+       public process_events() { this.peer_manager.process_events(); }
+
+       private descriptor_count = BigInt(0);
+       private get_descriptor(socket: net.Socket): ldk.SocketDescriptor {
+               const this_index = this.descriptor_count;
+               this.descriptor_count += BigInt(1);
+
+               socket.setNoDelay(true);
+
+               const this_pm = this.peer_manager;
+               var sock_write_waiting = false;
+
+               let descriptor = ldk.SocketDescriptor.new_impl ({
+                       send_data(data: Uint8Array, resume_read: boolean): number {
+                               if (resume_read) socket.resume();
+
+                               if (sock_write_waiting) return 0;
+                               const written = socket.write(data);
+                               if (!written) sock_write_waiting = true;
+                               return data.length;
+                       },
+                       disconnect_socket(): void {
+                               socket.destroy();
+                       },
+                       eq(other: ldk.SocketDescriptor): boolean {
+                               return other.hash() == this.hash();
+                       },
+                       hash(): bigint {
+                               return this_index;
+                       }
+               } as ldk.SocketDescriptorInterface);
+
+               socket.on("drain", function() {
+                       if (sock_write_waiting) {
+                               if (!this_pm.write_buffer_space_avail(descriptor).is_ok()) {
+                                       descriptor.disconnect_socket();
+                               }
+                       }
+               });
+
+               socket.on("data", function(data) {
+                       const res = this_pm.read_event(descriptor, data);
+                       if (!res.is_ok()) descriptor.disconnect_socket();
+                       else if ((res as ldk.Result_boolPeerHandleErrorZ_OK).res) socket.pause();
+                       this_pm.process_events();
+               });
+
+               socket.on("close", function() {
+                       this_pm.socket_disconnected(descriptor);
+               });
+
+               return descriptor;
+       }
+
+       private static v4_addr_from_ip(ip: string, port: number): ldk.NetAddress {
+               const sockaddr = ip.split(".").map(parseFloat);
+               return ldk.NetAddress.constructor_ipv4(new Uint8Array(sockaddr), port);
+       }
+       private static v6_addr_from_ip(ip: string, port: number): ldk.NetAddress {
+               const sockaddr = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
+               const halves = ip.split("::"); // either one or two elements
+               const first_half = halves[0].split(":");
+               for (var idx = 0; idx < first_half.length; idx++) {
+                       const v = parseInt(first_half[idx], 16);
+                       sockaddr[idx*2] = v >> 8;
+                       sockaddr[idx*2 + 1] = v & 0xff;
+               }
+               if (halves.length == 2) {
+                       const second_half = halves[1].split(":");
+                       for (var idx = 0; idx < second_half.length; idx++) {
+                               const v = parseInt(second_half[second_half.length - idx - 1], 16);
+                               sockaddr[14 - idx*2] = v >> 8;
+                               sockaddr[15 - idx*2] = v & 0xff;
+                       }
+               }
+               return ldk.NetAddress.constructor_ipv6(new Uint8Array(sockaddr), port);
+       }
+
+       private static get_addr_from_socket(socket: net.Socket): ldk.Option_NetAddressZ {
+               const addr = socket.remoteAddress;
+               if (addr === undefined)
+                       return ldk.Option_NetAddressZ.constructor_none();
+               if (net.isIPv4(addr)) {
+                       return ldk.Option_NetAddressZ.constructor_some(NodeLDKNet.v4_addr_from_ip(addr, socket.remotePort));
+               }
+               if (net.isIPv6(addr)) {
+                       return ldk.Option_NetAddressZ.constructor_some(NodeLDKNet.v6_addr_from_ip(addr, socket.remotePort));
+               }
+               return ldk.Option_NetAddressZ.constructor_none();
+       }
+
+       /**
+        * Binds a listener on the given host and port, accepting incoming connections.
+        */
+       public async bind_listener(host: string, port: number) {
+               const this_handler = this;
+               const server = net.createServer(function(incoming_sock: net.Socket) {
+                       const descriptor = this_handler.get_descriptor(incoming_sock);
+                       const res = this_handler.peer_manager
+                               .new_inbound_connection(descriptor, NodeLDKNet.get_addr_from_socket(incoming_sock));
+                       if (!res.is_ok()) descriptor.disconnect_socket();
+               });
+               const servers_list = this.servers;
+               return new Promise<void>((resolve, reject) => {
+                       server.on("error", function() {
+                               reject();
+                               server.close();
+                       });
+                       server.on("listening", function() {
+                               servers_list.push(server);
+                               resolve();
+                       });
+                       server.listen(port, host);
+               });
+       }
+
+       /**
+        * Establishes an outgoing connection to the given peer at the given host and port.
+        *
+        * Note that the peer will not appear in the PeerManager peers list until the socket has
+        * connected and the initial handshake completes.
+        */
+       public async connect_peer(host: string, port: number, peer_node_id: Uint8Array) {
+               const this_handler = this;
+               const sock = new net.Socket();
+               const res = new Promise<void>((resolve, reject) => {
+                       sock.on("connect", function() { resolve(); });
+                       sock.on("error", function() { reject(); });
+               });
+               sock.connect(port, host, function() {
+                       const descriptor = this_handler.get_descriptor(sock);
+                       const res = this_handler.peer_manager
+                               .new_outbound_connection(peer_node_id, descriptor, NodeLDKNet.get_addr_from_socket(sock));
+                       if (!res.is_ok()) descriptor.disconnect_socket();
+                       else {
+                               const bytes = (res as ldk.Result_CVec_u8ZPeerHandleErrorZ_OK).res;
+                               const send_res = descriptor.send_data(bytes, true);
+                               console.assert(send_res == bytes.length);
+                       }
+               });
+               return res;
+       }
+}
diff --git a/node-net/package.json b/node-net/package.json
new file mode 100644 (file)
index 0000000..e0b8a0e
--- /dev/null
@@ -0,0 +1,34 @@
+{
+  "name": "lightningdevkit-node-net",
+  "version": "Set in genbindings.sh automagically",
+  "description": "Lightning Development Kit Net Implementation for Node.JS",
+  "main": "net.mjs",
+  "types": "net.d.mts",
+  "type": "module",
+  "files": [
+    "net.mjs",
+    "net.d.mts",
+    "tsconfig.json",
+    "README.md"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/lightningdevkit/ldk-garbagecollected.git"
+  },
+  "keywords": [
+    "lightning",
+    "bitcoin"
+  ],
+  "author": "LDK Developers",
+  "license": "MIT OR Apache-2.0",
+  "bugs": {
+    "url": "https://github.com/lightningdevkit/ldk-garbagecollected/issues"
+  },
+  "homepage": "https://github.com/lightningdevkit/ldk-garbagecollected#readme",
+  "dependencies": {
+    "lightningdevkit": "Set in genbindings.sh automagically"
+  },
+  "devDependencies": {
+    "@types/node": "^17.0.8"
+  }
+}
diff --git a/node-net/test/test.mts b/node-net/test/test.mts
new file mode 100644 (file)
index 0000000..8d493e4
--- /dev/null
@@ -0,0 +1,96 @@
+import * as ldk from "../../ts/index.mjs";
+import * as node_net from '../net.mjs';
+
+import * as fs from 'fs';
+
+const wasm_file = fs.readFileSync('../ts/liblightningjs.wasm');
+await ldk.initializeWasmFromBinary(wasm_file);
+
+const logger_a = ldk.Logger.new_impl({
+       log(record: ldk.Record): void {
+               console.log(record.get_module_path() + ": " + record.get_args());
+       }
+} as ldk.LoggerInterface);
+const logger_b = logger_a;
+
+const node_a_secret = new Uint8Array(32);
+for (var i = 0; i < 32; i++) node_a_secret[i] = 42;
+// The public key for a secret key of all 42s:
+const node_a_pk = new Uint8Array([3, 91, 229, 233, 71, 130, 9, 103, 74, 150, 230, 15, 31, 3, 127, 97, 118, 84, 15, 208, 1, 250, 29, 100, 105, 71, 112, 197, 106, 119, 9, 196, 44]);
+
+const node_b_secret = new Uint8Array(32);
+for (var i = 0; i < 32; i++) node_b_secret[i] = 43;
+
+const rng_seed = new Uint8Array(32);
+const routing_handler = ldk.IgnoringMessageHandler.constructor_new().as_RoutingMessageHandler();
+const chan_handler = ldk.ErroringMessageHandler.constructor_new().as_ChannelMessageHandler();
+const cust_handler = ldk.IgnoringMessageHandler.constructor_new().as_CustomMessageHandler();
+
+const a_pm = ldk.PeerManager.constructor_new(chan_handler, routing_handler, node_a_secret, rng_seed, logger_a, cust_handler);
+const a_net_handler = new node_net.NodeLDKNet(a_pm);
+var port = 10000;
+for (; port < 11000; port++) {
+       try {
+               // Try ports until we find one we can bind to.
+               await a_net_handler.bind_listener("127.0.0.1", port);
+               break;
+       } catch(_) {}
+}
+
+const b_pm = ldk.PeerManager.constructor_new(chan_handler, routing_handler, node_b_secret, rng_seed, logger_b, cust_handler);
+const b_net_handler = new node_net.NodeLDKNet(b_pm);
+await b_net_handler.connect_peer("127.0.0.1", port, node_a_pk);
+
+try {
+       // Ensure we get an error if we try to bind the same port twice.
+       await a_net_handler.bind_listener("127.0.0.1", port);
+       console.assert(false);
+} catch(_) {}
+
+await new Promise<void>(resolve => {
+       // Wait until the peers are connected and have exchanged the initial handshake
+       var timer: ReturnType<typeof setInterval>;
+       timer = setInterval(function() {
+               if (a_pm.get_peer_node_ids().length == 1 && b_pm.get_peer_node_ids().length == 1) {
+                       resolve();
+                       clearInterval(timer);
+               }
+       }, 500);
+});
+
+b_pm.disconnect_by_node_id(node_a_pk, false);
+await new Promise<void>(resolve => {
+       // Wait until A learns the connection is closed from the socket closure
+       var timer: ReturnType<typeof setInterval>;
+       timer = setInterval(function() {
+               if (a_pm.get_peer_node_ids().length == 0 && b_pm.get_peer_node_ids().length == 0) {
+                       resolve();
+                       clearInterval(timer);
+               }
+       }, 500);
+});
+
+a_net_handler.stop();
+b_net_handler.stop();
+
+function arr_eq(a: number[]|Uint8Array, b: number[]|Uint8Array): boolean {
+       return a.length == b.length && a.every((val, idx) => val == b[idx]);
+}
+
+const v4_parse = node_net.NodeLDKNet["v4_addr_from_ip"];
+console.assert((v4_parse("127.0.0.1", 4242) as ldk.NetAddress_IPv4).port == 4242);
+console.assert(arr_eq((v4_parse("127.0.0.1", 4242) as ldk.NetAddress_IPv4).addr, [127,0,0,1]));
+console.assert(arr_eq((v4_parse("0.0.0.0", 4242) as ldk.NetAddress_IPv4).addr, [0,0,0,0]));
+
+const v6_parse = node_net.NodeLDKNet["v6_addr_from_ip"];
+console.assert((v6_parse("::", 4242) as ldk.NetAddress_IPv4).port == 4242);
+console.assert(arr_eq((v6_parse("::", 4242) as ldk.NetAddress_IPv6).addr,
+       [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]));
+console.assert(arr_eq((v6_parse("fe80::", 4242) as ldk.NetAddress_IPv6).addr,
+       [0xfe,0x80,0,0,0,0,0,0,0,0,0,0,0,0,0,0]));
+console.assert(arr_eq((v6_parse("fe80::42", 4242) as ldk.NetAddress_IPv6).addr,
+       [0xfe,0x80,0,0,0,0,0,0,0,0,0,0,0,0,0,0x42]));
+console.assert(arr_eq((v6_parse("fe80:A:b::", 4242) as ldk.NetAddress_IPv6).addr,
+       [0xfe,0x80,0,0xa,0,0xb,0,0,0,0,0,0,0,0,0,0]));
+console.assert(arr_eq((v6_parse("2001:1:bad::beef:cafe", 4242) as ldk.NetAddress_IPv6).addr,
+       [0x20, 0x01, 0, 1, 0xb, 0xad, 0, 0, 0, 0, 0, 0, 0xbe, 0xef, 0xca, 0xfe]));
diff --git a/node-net/tsconfig.json b/node-net/tsconfig.json
new file mode 100644 (file)
index 0000000..4eb9eea
--- /dev/null
@@ -0,0 +1,37 @@
+{
+  "compilerOptions": {
+    "target": "es2021",
+    "module": "es2022",
+    "sourceMap": true,
+    "esModuleInterop": false,
+    "stripInternal": true,
+    "moduleResolution": "node",
+
+    "paths": {
+        "lightningdevkit": [ "../ts/index.mts" ]
+    },
+
+    "allowSyntheticDefaultImports": false,
+    "forceConsistentCasingInFileNames": true,
+    "declaration": true,
+    "strict": true,
+    "noImplicitAny": true,
+    "strictNullChecks": false,
+    "strictFunctionTypes": true,
+    "strictBindCallApply": true,
+    "strictPropertyInitialization": false,
+    "noImplicitThis": true,
+    "useUnknownInCatchVariables": true,
+    "alwaysStrict": true,
+    "noUnusedLocals": false,
+    "noUnusedParameters": false,
+    "exactOptionalPropertyTypes": false,
+    "noImplicitReturns": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedIndexedAccess": true,
+    "noImplicitOverride": true,
+    "noPropertyAccessFromIndexSignature": true,
+    "allowUnusedLabels": false,
+    "allowUnreachableCode": false
+  }
+}