UNPKG

@fails-components/webtransport

Version:

A component to add webtransport support (server and client) to node.js using libquiche

287 lines (269 loc) 10 kB
import { connect, constants as http2constants } from 'node:http2' import { Http2WebTransportSession } from '../session.js' import { Http2CapsuleParser } from './capsuleparser.js' import { logger } from '../../utils.js' const log = logger(`webtransport:http2:node:client(${process?.pid})`) export class Http2WebTransportClient { /** * @param {import('../../types.js').NativeClientOptions} args */ constructor(args) { let port = args?.port if (typeof port === 'undefined') port = 443 this.port = Number(port) this.hostname = args?.host || 'localhost' this.serverCertificateHashes = args?.serverCertificateHashes || undefined this.protocols = args?.protocols || [] this.localPort = Number(args?.localPort) || undefined this.allowPooling = args?.allowPooling || false this.forceIpv6 = args?.forceIpv6 || false this.initialStreamFlowControlWindow = args?.initialStreamFlowControlWindow || 16 * 1024 // 16 KB this.initialSessionFlowControlWindow = args?.initialSessionFlowControlWindow || 16 * 1024 // 16 KB this.initialBidirectionalStreams = args?.initialBidirectionalSendStreams || 100 this.initialUnidirectionalStreams = args?.initialUnidirectionalSendStreams || 100 this.streamShouldAutoTuneReceiveWindow = args.streamShouldAutoTuneReceiveWindow || true this.streamFlowControlWindowSizeLimit = args?.streamFlowControlWindowSizeLimit || 6 * 1024 * 1024 this.sessionShouldAutoTuneReceiveWindow = args.sessionShouldAutoTuneReceiveWindow || true this.sessionFlowControlWindowSizeLimit = args?.sessionFlowControlWindowSizeLimit || 15 * 1024 * 1024 /** @type {import('../../session.js').HttpClient} */ // @ts-ignore this.jsobj = undefined // the transport will set this } createTransport() { /** * @param {string} hostname * @param {import('node:tls').PeerCertificate} cert * */ const webTransportVerifier = (hostname, cert) => { if ( this.serverCertificateHashes && this.serverCertificateHashes.some((el) => { if (el.algorithm !== 'sha-256') return false const cbytes = cert.fingerprint256 .split(':') .map((el) => parseInt(el, 16)) const val = Buffer.isBuffer(el.value) ? el.value : new Uint8Array( ArrayBuffer.isView(el.value) ? el.value.buffer : el.value ) if (cbytes.length !== val.byteLength) return false for (let i = 0; i < val.byteLength; i++) { if (val[i] !== cbytes[i]) return false } const curdate = new Date() if ( new Date(cert.valid_from) > curdate || new Date(cert.valid_to) < curdate ) return false const difference = new Date(cert.valid_to).getTime() - new Date(cert.valid_from).getTime() if (difference > 1000 * 60 * 60 * 24 * 14) return false // no more than 14 days spec says. return true }) ) return true else return false } const http2Options = { settings: { enableConnectProtocol: true, customSettings: { 0x2b60: 1, // SETTINGS_WT_MAX_SESSIONS, TODO fix number 0x2b61: this.initialSessionFlowControlWindow, // SETTINGS_WT_INITIAL_MAX_DATA 0x2b62: this.initialStreamFlowControlWindow, // SETTINGS_WT_INITIAL_MAX_STREAM_DATA_UNI 0x2b63: this.initialStreamFlowControlWindow, // SETTINGS_WT_INITIAL_MAX_STREAM_DATA_BIDI 0x2b64: this.initialUnidirectionalStreams, // SETTINGS_WT_INITIAL_MAX_STREAMS_UNI 0x2b65: this.initialBidirectionalStreams // SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI } }, remoteCustomSettings: [0x2b60, 0x2b61, 0x2b62, 0x2b63, 0x2b64, 0x2b65], localPort: this.localPort, // TODO: REMOVE BEFORE RELEASE; UNSAFE SETTING rejectUnauthorized: !this.serverCertificateHashes } if (this.serverCertificateHashes) // @ts-ignore http2Options.checkServerIdentity = webTransportVerifier // @ts-ignore this.clientInt = connect( 'https://' + this.hostname + ':' + this.port, http2Options ) /** @type {NodeJS.Timeout|undefined} */ let pingsender this.clientInt.on('close', () => { if (pingsender) clearInterval(pingsender) }) let authfail = false this.clientInt.socket.on('secureConnect', () => { /** @type {import('node:tls').TLSSocket} */ // @ts-ignore const oursocket = this.clientInt?.socket if (!oursocket) throw new Error('Can not get http2 TLSSocket') // @ts-ignore if (!oursocket.authorized) { // ok last hope we have hashes if (this.serverCertificateHashes) { if ( !webTransportVerifier(this.hostname, oursocket.getPeerCertificate()) ) { this.clientInt?.destroy( undefined, http2constants.NGHTTP2_REFUSED_STREAM ) log('Certificate hash does not match') authfail = true this.jsobj.onClientConnected({ success: false }) } else { oursocket.authorized = true } } else { this.clientInt?.destroy( new Error('Certificate not authorized'), http2constants.NGHTTP2_REFUSED_STREAM ) authfail = true log('Certificate not authorized') this.jsobj.onClientConnected({ success: false }) } } }) let connected = false // eslint-disable-next-line no-unused-vars this.clientInt.on('connect', (session, socket) => { if (!authfail) { connected = true this.jsobj.onClientConnected({ success: true }) let rtt = 100 let adjust = 1 // ok we got a session and want to measure RTT const pingupdater = () => { if (this.clientInt && !this.clientInt.closed) { // eslint-disable-next-line no-unused-vars this.clientInt.ping((err, duration, payload) => { if (!err) { rtt = adjust * duration + (1 - adjust) * rtt adjust = 0.2 // @ts-ignore this.clientInt.WTrtt = rtt } }) } else { clearInterval(pingsender) // @ts-ignore pingsender = undefined } } pingsender = setInterval(pingupdater, 1000) pingupdater() } }) this.clientInt.on('error', (error) => { log('http2 client error:', error) if (!connected && !authfail) { this.jsobj.onClientConnected({ success: false }) } }) this.clientInt.on('remoteSettings', (settings) => { if (settings.enableConnectProtocol && this.clientInt) { // if (settings.webtansportmaxsessions) { const retObj = {} this.jsobj.onClientWebTransportSupport(retObj) } } }) } /** * @param {string} path */ openWTSession(path) { if (!this.clientInt) throw new Error('clientInt not present') const requestOpts = { ':method': 'CONNECT', ':protocol': 'webtransport', ':scheme': 'https', ':path': path, authority: this.hostname, origin: this.hostname } if (this.protocols.length > 0) { // @ts-ignore requestOpts['wt-available-protocols'] = '"' + this.protocols .map((el) => el.replace(/\\/g, '\\\\').replace(/"/g, '\\"')) .join('","') + '"' } const stream = this.clientInt.request(requestOpts) const { 0x2b65: remoteBidirectionalStreams = undefined, 0x2b64: remoteUnidirectionalStreams = undefined, 0x2b63: remoteBidirectionalStreamFlowControlWindow = undefined, 0x2b62: remoteUnidirectionalStreamFlowControlWindow = undefined, 0x2b61: remoteSessionFlowControlWindow = undefined // @ts-ignore } = this.clientInt.remoteSettings?.customSettings || {} const retObj = { header: stream.sentHeaders, session: new Http2WebTransportSession({ stream, isclient: true, createParser: (/** @type {Http2WebTransportSession} */ nativesession) => new Http2CapsuleParser({ stream, nativesession, isclient: true, initialStreamSendWindowOffsetBidi: remoteBidirectionalStreamFlowControlWindow || this.initialStreamFlowControlWindow, initialStreamSendWindowOffsetUnidi: remoteUnidirectionalStreamFlowControlWindow || this.initialStreamFlowControlWindow, initialStreamReceiveWindowOffset: this.initialStreamFlowControlWindow, streamShouldAutoTuneReceiveWindow: this.streamShouldAutoTuneReceiveWindow, streamReceiveWindowSizeLimit: this.streamFlowControlWindowSizeLimit }), initialBidirectionalSendStreams: remoteBidirectionalStreams || this.initialBidirectionalStreams, initialBidirectionalReceiveStreams: this.initialBidirectionalStreams, initialUnidirectionalSendStreams: remoteUnidirectionalStreams || this.initialUnidirectionalStreams, initialUnidirectionalReceiveStreams: this.initialUnidirectionalStreams, sendWindowOffset: remoteSessionFlowControlWindow || this.sessionFlowControlWindowSizeLimit, receiveWindowOffset: this.initialSessionFlowControlWindow, shouldAutoTuneReceiveWindow: this.sessionShouldAutoTuneReceiveWindow, receiveWindowSizeLimit: this.sessionFlowControlWindowSizeLimit }), reliable: true } this.jsobj.onHttpWTSessionVisitor(retObj) } closeClient() { if (this.clientInt) this.clientInt.close() } }