UNPKG

@libp2p/websockets

Version:

JavaScript implementation of the WebSockets module that libp2p uses and that implements the interface-transport spec

308 lines • 12.2 kB
import http from 'node:http'; import https from 'node:https'; import net from 'node:net'; import { getThinWaistAddresses } from '@libp2p/utils/get-thin-waist-addresses'; import { ipPortToMultiaddr as toMultiaddr } from '@libp2p/utils/ip-port-to-multiaddr'; import { multiaddr } from '@multiformats/multiaddr'; import { WebSockets, WebSocketsSecure } from '@multiformats/multiaddr-matcher'; import duplex from 'it-ws/duplex'; import { TypedEventEmitter, setMaxListeners } from 'main-event'; import { pEvent } from 'p-event'; import * as ws from 'ws'; import { socketToMaConn } from './socket-to-conn.js'; export class WebSocketListener extends TypedEventEmitter { log; logger; server; wsServer; metrics; sockets; upgrader; httpOptions; httpsOptions; shutdownController; http; https; addr; listeningMultiaddr; constructor(components, init) { super(); this.log = components.logger.forComponent('libp2p:websockets:listener'); this.logger = components.logger; this.upgrader = init.upgrader; this.httpOptions = init.http; this.httpsOptions = init.https ?? init.http; this.sockets = new Set(); this.shutdownController = new AbortController(); setMaxListeners(Infinity, this.shutdownController.signal); this.wsServer = new ws.WebSocketServer({ noServer: true }); this.wsServer.addListener('connection', this.onWsServerConnection.bind(this)); components.metrics?.registerMetricGroup('libp2p_websockets_inbound_connections_total', { label: 'address', help: 'Current active connections in WebSocket listener', calculate: () => { if (this.addr == null) { return {}; } return { [this.addr]: this.sockets.size }; } }); this.metrics = { status: components.metrics?.registerMetricGroup('libp2p_websockets_listener_status_info', { label: 'address', help: 'Current status of the WebSocket listener socket' }), errors: components.metrics?.registerMetricGroup('libp2p_websockets_listener_errors_total', { label: 'address', help: 'Total count of WebSocket listener errors by type' }), events: components.metrics?.registerMetricGroup('libp2p_websockets_listener_events_total', { label: 'address', help: 'Total count of WebSocket listener events by type' }) }; this.server = net.createServer({ pauseOnConnect: true }, (socket) => { this.onSocketConnection(socket) .catch(err => { this.log.error('error handling socket - %e', err); socket.destroy(); }); }); components.events.addEventListener('certificate:provision', this.onCertificateProvision.bind(this)); components.events.addEventListener('certificate:renew', this.onCertificateRenew.bind(this)); } async onSocketConnection(socket) { this.metrics.events?.increment({ [`${this.addr} connection`]: true }); let buffer = socket.read(1); if (buffer == null) { await pEvent(socket, 'readable'); buffer = socket.read(1); } // determine if this is an HTTP(s) request const byte = buffer[0]; let server = this.http; // https://github.com/mscdex/httpolyglot/blob/1c6c4af65f4cf95a32c918d0fdcc532e0c095740/lib/index.js#L92 if (byte < 32 || byte >= 127) { server = this.https; } if (server == null) { this.log.error('no appropriate listener configured for byte %d', byte); socket.destroy(); return; } // store the socket so we can close it when the listener closes this.sockets.add(socket); socket.on('close', () => { this.metrics.events?.increment({ [`${this.addr} close`]: true }); this.sockets.delete(socket); }); socket.on('error', (err) => { this.log.error('socket error - %e', err); this.metrics.events?.increment({ [`${this.addr} error`]: true }); socket.destroy(); }); socket.once('timeout', () => { this.metrics.events?.increment({ [`${this.addr} timeout`]: true }); }); socket.once('end', () => { this.metrics.events?.increment({ [`${this.addr} end`]: true }); }); // re-queue first data chunk socket.unshift(buffer); // hand the socket off to the appropriate server server.emit('connection', socket); } onWsServerConnection(socket, req) { let addr; try { addr = this.server.address(); if (typeof addr === 'string') { throw new Error('Cannot listen on unix sockets'); } if (addr == null) { throw new Error('Server was closing or not running'); } } catch (err) { this.log.error('error obtaining remote socket address - %e', err); req.destroy(err); socket.close(); return; } const stream = { ...duplex(socket, { remoteAddress: req.socket.remoteAddress ?? '0.0.0.0', remotePort: req.socket.remotePort ?? 0 }), localAddress: addr.address, localPort: addr.port }; let maConn; try { maConn = socketToMaConn(stream, toMultiaddr(stream.remoteAddress ?? '', stream.remotePort ?? 0), { logger: this.logger, metrics: this.metrics?.events, metricPrefix: `${this.addr} ` }); } catch (err) { this.log.error('inbound connection failed', err); this.metrics.errors?.increment({ [`${this.addr} inbound_to_connection`]: true }); socket.close(); return; } this.log('new inbound connection %s', maConn.remoteAddr); this.upgrader.upgradeInbound(maConn, { signal: this.shutdownController.signal }) .catch(async (err) => { this.log.error('inbound connection failed to upgrade - %e', err); this.metrics.errors?.increment({ [`${this.addr} inbound_upgrade`]: true }); await maConn.close() .catch(err => { this.log.error('inbound connection failed to close after upgrade failed', err); this.metrics.errors?.increment({ [`${this.addr} inbound_closing_failed`]: true }); }); }); } onUpgrade(req, socket, head) { this.wsServer.handleUpgrade(req, socket, head, this.onWsServerConnection.bind(this)); } onTLSClientError(err, socket) { this.log.error('TLS client error - %e', err); socket.destroy(); } async listen(ma) { if (WebSockets.exactMatch(ma)) { this.http = http.createServer(this.httpOptions ?? {}, this.httpRequestHandler.bind(this)); this.http.addListener('upgrade', this.onUpgrade.bind(this)); } else if (WebSocketsSecure.exactMatch(ma)) { this.https = https.createServer(this.httpsOptions ?? {}, this.httpRequestHandler.bind(this)); this.https.addListener('upgrade', this.onUpgrade.bind(this)); this.https.addListener('tlsClientError', this.onTLSClientError.bind(this)); } const options = ma.toOptions(); this.addr = `${options.host}:${options.port}`; this.server.listen({ ...options, ipv6Only: options.family === 6 }); await new Promise((resolve, reject) => { const onListening = () => { removeListeners(); resolve(); }; const onError = (err) => { this.metrics.errors?.increment({ [`${this.addr} listen_error`]: true }); removeListeners(); reject(err); }; const onDrop = () => { this.metrics.events?.increment({ [`${this.addr} drop`]: true }); }; const removeListeners = () => { this.server.removeListener('listening', onListening); this.server.removeListener('error', onError); this.server.removeListener('drop', onDrop); }; this.server.addListener('listening', onListening); this.server.addListener('error', onError); this.server.addListener('drop', onDrop); }); this.listeningMultiaddr = ma; this.safeDispatchEvent('listening'); } onCertificateProvision(event) { if (this.https != null) { this.log('auto-tls certificate found but already listening on https'); return; } this.log('auto-tls certificate found, starting https server'); this.https = https.createServer({ ...this.httpsOptions, ...event.detail }, this.httpRequestHandler.bind(this)); this.https.addListener('upgrade', this.onUpgrade.bind(this)); this.https.addListener('tlsClientError', this.onTLSClientError.bind(this)); this.safeDispatchEvent('listening'); } onCertificateRenew(event) { // stop accepting new connections this.https?.close(); this.log('auto-tls certificate renewed, restarting https server'); this.https = https.createServer({ ...this.httpsOptions, ...event.detail }, this.httpRequestHandler.bind(this)); this.https.addListener('upgrade', this.onUpgrade.bind(this)); this.https.addListener('tlsClientError', this.onTLSClientError.bind(this)); } async close() { this.server.close(); this.http?.close(); this.https?.close(); this.wsServer.close(); // close all connections, must be done after closing the server to prevent // race conditions where a new connection is accepted while we are closing // the existing ones this.http?.closeAllConnections(); this.https?.closeAllConnections(); [...this.sockets].forEach(socket => { socket.destroy(); }); // abort and in-flight connection upgrades this.shutdownController.abort(); await Promise.all([ pEvent(this.server, 'close'), this.http == null ? null : pEvent(this.http, 'close'), this.https == null ? null : pEvent(this.https, 'close'), pEvent(this.wsServer, 'close') ]); this.safeDispatchEvent('close'); } getAddrs() { const address = this.server.address(); if (address == null) { return []; } if (typeof address === 'string') { // TODO: wrap with encodeURIComponent https://github.com/multiformats/multiaddr/pull/174 return [multiaddr(`/unix/${address}/ws`)]; } const multiaddrs = getThinWaistAddresses(this.listeningMultiaddr, address.port); const insecureMultiaddrs = []; if (this.http != null) { multiaddrs.forEach(ma => { insecureMultiaddrs.push(ma.encapsulate('/ws')); }); } const secureMultiaddrs = []; if (this.https != null) { multiaddrs.forEach(ma => { secureMultiaddrs.push(ma.encapsulate('/tls/ws')); }); } return [ ...insecureMultiaddrs, ...secureMultiaddrs ]; } updateAnnounceAddrs() { } httpRequestHandler(req, res) { res.writeHead(400); res.write('Only WebSocket connections are supported'); res.end(); } } export function createListener(components, init) { return new WebSocketListener(components, init); } //# sourceMappingURL=listener.js.map