diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 37bcad347d..9805eec10f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -46,6 +46,10 @@ jobs: fail-fast: true steps: - uses: actions/checkout@v4 + # needed for webtransport tests, remove after https://github.com/libp2p/js-libp2p/pull/2422 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 + with: + go-version: '1.22' - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} @@ -147,6 +151,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # needed for webtransport tests, remove after https://github.com/libp2p/js-libp2p/pull/2422 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 + with: + go-version: '1.22' - uses: actions/setup-node@v4 with: node-version: lts/* diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index cfa11c3b72..1afd01eb2d 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -48,7 +48,7 @@ "@libp2p/interface": "^1.3.1", "@libp2p/interface-compliance-tests": "^5.4.4", "@libp2p/interface-internal": "^1.2.1", - "@libp2p/interop": "^11.0.0", + "@libp2p/interop": "^12.0.2", "@libp2p/kad-dht": "^12.0.16", "@libp2p/logger": "^4.0.12", "@libp2p/mdns": "^10.0.23", diff --git a/packages/integration-tests/test/interop.ts b/packages/integration-tests/test/interop.ts index 66693528fc..e91d9f0425 100644 --- a/packages/integration-tests/test/interop.ts +++ b/packages/integration-tests/test/interop.ts @@ -15,6 +15,7 @@ import { mplex } from '@libp2p/mplex' import { peerIdFromKeys } from '@libp2p/peer-id' import { tcp } from '@libp2p/tcp' import { tls } from '@libp2p/tls' +import { webTransport } from '@libp2p/webtransport' import { multiaddr } from '@multiformats/multiaddr' import { execa } from 'execa' import { path as p2pd } from 'go-libp2p' @@ -46,7 +47,11 @@ async function createGoPeer (options: SpawnOptions): Promise { if (options.noListen === true) { opts.push('-noListenAddrs') } else { - opts.push('-hostAddrs=/ip4/127.0.0.1/tcp/0') + if (options.transport === 'webtransport') { + opts.push('-hostAddrs=/ip4/127.0.0.1/udp/0/quic-v1/webtransport') + } else { + opts.push('-hostAddrs=/ip4/127.0.0.1/tcp/0') + } } if (options.encryption != null) { @@ -122,9 +127,9 @@ async function createJsPeer (options: SpawnOptions): Promise { const opts: Libp2pOptions = { peerId, addresses: { - listen: options.noListen === true ? [] : ['/ip4/127.0.0.1/tcp/0'] + listen: [] }, - transports: [tcp(), circuitRelayTransport()], + transports: [tcp(), circuitRelayTransport(), webTransport()], streamMuxers: [], connectionEncryption: [noise()], connectionManager: { @@ -132,6 +137,12 @@ async function createJsPeer (options: SpawnOptions): Promise { } } + if (options.transport === 'webtransport') { + opts.addresses?.listen?.push('/ip4/127.0.0.1/udp/0/quic-v1/webtransport') + } else if (options.transport === 'tcp') { + opts.addresses?.listen?.push('/ip4/127.0.0.1/tcp/0') + } + const services: ServiceFactoryMap = { identify: identify() } diff --git a/packages/transport-webtransport/.aegir.js b/packages/transport-webtransport/.aegir.js index 6a6b15a72c..15b5f4f30b 100644 --- a/packages/transport-webtransport/.aegir.js +++ b/packages/transport-webtransport/.aegir.js @@ -1,5 +1,3 @@ -import { createClient } from '@libp2p/daemon-client' -import { multiaddr } from '@multiformats/multiaddr' import { execa } from 'execa' import { path as p2pd } from 'go-libp2p' import pDefer from 'p-defer' @@ -27,6 +25,12 @@ export default { } async function createGoLibp2p () { + // have to import these dynamically as they have a transitive dependency on + // @libp2p/interface which is part of this monorepo so may not have been built + // yet + const { multiaddr } = await import('@multiformats/multiaddr') + const { createClient } = await import('@libp2p/daemon-client') + const controlPort = Math.floor(Math.random() * (50000 - 10000 + 1)) + 10000 const apiAddr = multiaddr(`/ip4/127.0.0.1/tcp/${controlPort}`) const deferred = pDefer() diff --git a/packages/transport-webtransport/package.json b/packages/transport-webtransport/package.json index d3d9277833..f11cb7c36e 100644 --- a/packages/transport-webtransport/package.json +++ b/packages/transport-webtransport/package.json @@ -42,20 +42,25 @@ "scripts": { "clean": "aegir clean", "lint": "aegir lint", - "dep-check": "aegir dep-check", + "dep-check": "aegir dep-check -i @fails-components/webtransport-transport-http3-quiche", "doc-check": "aegir doc-check", "build": "aegir build", - "test": "aegir test -t browser -t webworker", + "test": "aegir test", + "test:node": "aegir test -t node --cov", "test:chrome": "aegir test -t browser --cov", "test:chrome-webworker": "aegir test -t webworker" }, "dependencies": { "@chainsafe/libp2p-noise": "^15.0.0", + "@fails-components/webtransport": "^1.1.0", + "@fails-components/webtransport-transport-http3-quiche": "^1.1.0", "@libp2p/interface": "^1.3.1", "@libp2p/peer-id": "^4.1.1", "@libp2p/utils": "^5.4.1", "@multiformats/multiaddr": "^12.2.3", "@multiformats/multiaddr-matcher": "^1.2.1", + "@peculiar/x509": "^1.9.7", + "browser-readablestream-to-it": "^2.0.5", "it-stream-types": "^2.0.1", "multiformats": "^13.1.0", "race-signal": "^1.0.2", @@ -63,9 +68,10 @@ "uint8arrays": "^5.1.0" }, "devDependencies": { - "@libp2p/daemon-client": "^8.0.5", + "@libp2p/interface-compliance-tests": "^5.4.4", "@libp2p/logger": "^4.0.12", "@libp2p/peer-id-factory": "^4.1.1", + "@libp2p/daemon-client": "^8.0.5", "@libp2p/ping": "^1.0.18", "@noble/hashes": "^1.4.0", "aegir": "^42.2.11", @@ -75,7 +81,8 @@ "it-to-buffer": "^4.0.7", "libp2p": "^1.5.2", "p-defer": "^4.0.1", - "p-wait-for": "^5.0.2" + "p-wait-for": "^5.0.2", + "sinon": "^18.0.0" }, "browser": { "./dist/src/listener.js": "./dist/src/listener.browser.js", diff --git a/packages/transport-webtransport/src/create-server.ts b/packages/transport-webtransport/src/create-server.ts new file mode 100644 index 0000000000..36d0d9b717 --- /dev/null +++ b/packages/transport-webtransport/src/create-server.ts @@ -0,0 +1,150 @@ +import { Http3Server } from '@fails-components/webtransport' +import { TypedEventEmitter } from '@libp2p/interface' +import { raceSignal } from 'race-signal' +import type { HttpServerInit, WebTransportSession } from '@fails-components/webtransport' +import type { ComponentLogger, Logger, TypedEventTarget } from '@libp2p/interface' + +interface WebTransportServerEvents extends Record { + listening: CustomEvent + session: CustomEvent + close: CustomEvent + error: CustomEvent +} + +export interface WebTransportServer extends TypedEventTarget { + listening: boolean + sessionTimeout: number + + close(callback?: () => void): void + listen(): void + address(): { port: number, host: string, family: 'IPv4' | 'IPv6' } | null +} + +export interface WebTransportServerComponents { + logger: ComponentLogger +} + +class DefaultWebTransportServer extends TypedEventEmitter implements WebTransportServer { + private readonly server: Http3Server + public listening: boolean + /** + * How long in ms to wait for an incoming session to be ready + */ + public sessionTimeout: number + private readonly log: Logger + + constructor (components: WebTransportServerComponents, init: HttpServerInit) { + super() + + this.server = new Http3Server(init) + this.listening = false + this.log = components.logger.forComponent('libp2p:webtransport:server') + + this.sessionTimeout = 1000 + } + + close (callback?: () => void): void { + if (callback != null) { + this.addEventListener('close', callback) + } + + this.server.stopServer() + this.server.closed + .then(() => { + this.listening = false + this.safeDispatchEvent('close') + }) + .catch((err) => { + this.safeDispatchEvent('error', { detail: err }) + }) + } + + listen (): void { + this.server.startServer() + this.server.ready + .then(() => { + this.server.setRequestCallback(async (args: any): Promise => { + const url = args.header[':path'] + const [path] = url.split('?') + + if (this.server.sessionController[path] == null) { + return { + ...args, + path, + status: 404 + } + } + + return { + ...args, + path, + userData: { + search: url.substring(path.length) + }, + header: { + ...args.header, + ':path': path + }, + status: 200 + } + }) + + this.listening = true + this.safeDispatchEvent('listening') + + this.log('ready, processing incoming sessions') + this._processIncomingSessions() + .catch(err => { + this.safeDispatchEvent('error', { detail: err }) + }) + }) + .catch((err) => { + this.safeDispatchEvent('error', { detail: err }) + }) + } + + address (): { port: number, host: string, family: 'IPv4' | 'IPv6' } | null { + return this.server.address() + } + + async _processIncomingSessions (): Promise { + const sessionStream = this.server.sessionStream('/.well-known/libp2p-webtransport') + const sessionReader = sessionStream.getReader() + + while (true) { + const { done, value: session } = await sessionReader.read() + + if (done) { + this.log('session reader finished') + break + } + + this.log('new incoming session') + void Promise.resolve() + .then(async () => { + try { + await raceSignal(session.ready, AbortSignal.timeout(this.sessionTimeout)) + this.log('session ready') + + this.safeDispatchEvent('session', { detail: session }) + } catch (err) { + this.log.error('error waiting for session to become ready', err) + } + }) + } + } +} + +export interface SessionHandler { + (event: CustomEvent): void +} + +export function createServer (components: WebTransportServerComponents, init: HttpServerInit, sessionHandler?: SessionHandler): WebTransportServer { + const server = new DefaultWebTransportServer(components, init) + + if (sessionHandler != null) { + server.addEventListener('session', sessionHandler) + } + + return server +} diff --git a/packages/transport-webtransport/src/index.ts b/packages/transport-webtransport/src/index.ts index 1ebe1cd508..6e3e1b2e93 100644 --- a/packages/transport-webtransport/src/index.ts +++ b/packages/transport-webtransport/src/index.ts @@ -191,6 +191,10 @@ class WebTransportTransport implements Transport { throw new CodeError('Failed to authenticate webtransport', 'ERR_AUTHENTICATION_FAILED') } + if (options?.signal?.aborted === true) { + throw new AbortError() + } + this.metrics?.dialerEvents.increment({ open: true }) maConn = { @@ -303,8 +307,8 @@ class WebTransportTransport implements Transport { /** * Filter check for all Multiaddrs that this transport can listen on */ - listenFilter (): Multiaddr[] { - return [] + listenFilter (multiaddrs: Multiaddr[]): Multiaddr[] { + return multiaddrs.filter(ma => WebTransportMatcher.exactMatch(ma)) } /** diff --git a/packages/transport-webtransport/src/listener.ts b/packages/transport-webtransport/src/listener.ts index a0e15ff49d..c01b51458f 100644 --- a/packages/transport-webtransport/src/listener.ts +++ b/packages/transport-webtransport/src/listener.ts @@ -1,5 +1,56 @@ +import * as os from 'node:os' +import { noise } from '@chainsafe/libp2p-noise' +import { TypedEventEmitter } from '@libp2p/interface' +import { ipPortToMultiaddr } from '@libp2p/utils/ip-port-to-multiaddr' +import { multiaddr } from '@multiformats/multiaddr' +import toIt from 'browser-readablestream-to-it' +import { base64url } from 'multiformats/bases/base64' +import { createServer } from './create-server.js' +import { webtransportMuxer } from './muxer.js' +import { generateWebTransportCertificates } from './utils/generate-certificates.js' +import { inertDuplex } from './utils/inert-duplex.js' +import type { WebTransportServer } from './create-server.js' import type { WebTransportCertificate } from './index.js' -import type { Connection, Upgrader, Listener, CreateListenerOptions, PeerId, ComponentLogger, Metrics } from '@libp2p/interface' +import type { WebTransportSession } from '@fails-components/webtransport' +import type { MultiaddrConnection, Connection, Upgrader, Listener, ListenerEvents, CreateListenerOptions, PeerId, ComponentLogger, Metrics, Logger } from '@libp2p/interface' +import type { Multiaddr } from '@multiformats/multiaddr' +import type { Duplex, Source } from 'it-stream-types' +import type { Uint8ArrayList } from 'uint8arraylist' + +const CODE_P2P = 421 + +const networks = os.networkInterfaces() + +function isAnyAddr (ip: string): boolean { + return ['0.0.0.0', '::'].includes(ip) +} + +function getNetworkAddrs (family: string): string[] { + const addresses: string[] = [] + + for (const [, netAddrs] of Object.entries(networks)) { + if (netAddrs != null) { + for (const netAddr of netAddrs) { + if (netAddr.family === family) { + addresses.push(netAddr.address) + } + } + } + } + + return addresses +} + +const ProtoFamily = { ip4: 'IPv4', ip6: 'IPv6' } + +function getMultiaddrs (proto: 'ip4' | 'ip6', ip: string, port: number, certificates: WebTransportCertificate[] = []): Multiaddr[] { + const certhashes = certificates.map(cert => { + return `/certhash/${base64url.encode(cert.hash.bytes)}` + }).join('') + + const toMa = (ip: string): Multiaddr => multiaddr(`/${proto}/${ip}/udp/${port}/quic-v1/webtransport${certhashes}`) + return (isAnyAddr(ip) ? getNetworkAddrs(ProtoFamily[proto]) : [ip]).map(toMa) +} export interface WebTransportListenerComponents { peerId: PeerId @@ -14,6 +65,262 @@ export interface WebTransportListenerInit extends CreateListenerOptions { maxInboundStreams?: number } +type Status = { started: false } | { started: true, listeningAddr: Multiaddr, peerId: string | null } + +class WebTransportListener extends TypedEventEmitter implements Listener { + private server?: WebTransportServer + private certificates?: WebTransportCertificate[] + private readonly peerId: PeerId + private readonly upgrader: Upgrader + private readonly handler?: (conn: Connection) => void + /** Keep track of open connections to destroy in case of timeout */ + private readonly connections: Connection[] + private readonly components: WebTransportListenerComponents + private readonly log: Logger + private readonly maxInboundStreams: number + + private status: Status = { started: false } + + constructor (components: WebTransportListenerComponents, init: WebTransportListenerInit) { + super() + + this.components = components + this.certificates = init.certificates + this.peerId = components.peerId + this.upgrader = init.upgrader + this.handler = init.handler + this.connections = [] + this.log = components.logger.forComponent('libp2p:webtransport:listener') + this.maxInboundStreams = init.maxInboundStreams ?? 1000 + } + + async onSession (session: WebTransportSession): Promise { + if (!this._assertSupportsNoise(session)) { + session.close({ + closeCode: 1, + reason: 'Unsupported encryption' + }) + } + + const bidiReader = session.incomingBidirectionalStreams.getReader() + + // read one stream to do authentication + this.log('read authentication stream') + const bidistr = await bidiReader.read() + + if (bidistr.done) { + this.log.error('bidirectional stream reader ended before authentication stream received') + return + } + + // ok we got a stream + const bidistream = bidistr.value + const writer = bidistream.writable.getWriter() + + const encrypter = noise({ + extensions: { + webtransportCerthashes: this.certificates?.map(cert => cert.hash.bytes) ?? [] + } + })(this.components) + const duplex: Duplex> = { + source: toIt(bidistream.readable), + sink: async (source: Source) => { + for await (const buf of source) { + if (buf instanceof Uint8Array) { + await writer.write(buf) + } else { + await writer.write(buf.subarray()) + } + } + } + } + + this.log('secure inbound stream') + const { remotePeer } = await encrypter.secureInbound(this.peerId, duplex) + + // upgrade it + const maConn: MultiaddrConnection = { + close: async () => { + this.log('Closing webtransport gracefully') + session.close() + }, + abort: (err: Error) => { + this.log('Closing webtransport with err:', err) + session.close() + }, + // TODO: pull this from webtransport + // remoteAddr: ipPortToMultiaddr(session.remoteAddress, session.remotePort), + remoteAddr: ipPortToMultiaddr('127.0.0.1', 8080).encapsulate(`/p2p/${remotePeer.toString()}`), + timeline: { + open: Date.now() + }, + log: this.components.logger.forComponent(`libp2p:webtransport:listener:${0}`), + // This connection is never used directly since webtransport supports native streams + ...inertDuplex() + } + + session.closed.catch((err: Error) => { + this.log.error('WebTransport connection closed with error:', err) + }).finally(() => { + // This is how we specify the connection is closed and shouldn't be used. + maConn.timeline.close = Date.now() + }) + + try { + this.log('upgrade inbound stream') + const connection = await this.upgrader.upgradeInbound(maConn, { + skipEncryption: true, + skipProtection: true, + muxerFactory: webtransportMuxer( + session, + bidiReader, + this.components.logger, { + maxInboundStreams: this.maxInboundStreams + }) + }) + + this.log('upgrade complete, close authentication stream') + // We're done with this authentication stream + writer.close().catch((err: Error) => { + this.log.error('failed to close authentication stream writer', err) + }) + + this.connections.push(connection) + + if (this.handler != null) { + this.handler(connection) + } + + this.safeDispatchEvent('connection', { + detail: connection + }) + } catch (err: any) { + session.close({ + closeCode: 500, + reason: err.message + }) + } + } + + _assertSupportsNoise (session: any): boolean { + return session?.userData?.search?.includes('type=noise') + } + + getAddrs (): Multiaddr[] { + if (!this.status.started || this.server == null) { + return [] + } + + let addrs: Multiaddr[] = [] + const address = this.server.address() + + if (address == null) { + return [] + } + + try { + if (address.family === 'IPv4') { + addrs = addrs.concat(getMultiaddrs('ip4', address.host, address.port, this.certificates)) + } else if (address.family === 'IPv6') { + addrs = addrs.concat(getMultiaddrs('ip6', address.host, address.port, this.certificates)) + } + } catch (err) { + this.log.error('could not turn %s:%s into multiaddr', address.host, address.port, err) + } + + return addrs.map(ma => this.peerId != null ? ma.encapsulate(`/p2p/${this.peerId.toString()}`) : ma) + } + + async listen (ma: Multiaddr): Promise { + this.log('listen on multiaddr %s', ma) + let certificates = this.certificates + + if (certificates == null || certificates.length === 0) { + this.log('generating certificates') + + certificates = this.certificates = await generateWebTransportCertificates([{ + // can be max 14 days according to the spec + days: 13 + }, { + days: 13, + // start in 12 days time + start: new Date(Date.now() + (86400000 * 12)) + }]) + } + + const peerId = ma.getPeerId() + const listeningAddr = peerId == null ? ma.decapsulateCode(CODE_P2P) : ma + + this.status = { started: true, listeningAddr, peerId } + + const options = listeningAddr.toOptions() + + const server = this.server = createServer(this.components, { + port: options.port, + host: options.host, + secret: certificates[0].secret, + cert: certificates[0].pem, + privKey: certificates[0].privateKey + }) + + server.addEventListener('listening', () => { + this.log('server listening %s', ma, server.address()) + this.safeDispatchEvent('listening') + }) + server.addEventListener('error', (event) => { + this.log('server error %s', ma, event.detail) + this.safeDispatchEvent('error', { detail: event.detail }) + }) + server.addEventListener('session', (event) => { + this.log('server new session %s', ma) + this.onSession(event.detail) + .catch(err => { + this.log.error('error handling new session', err) + event.detail.close() + }) + }) + server.addEventListener('close', () => { + this.log('server close %s', ma) + this.safeDispatchEvent('close') + }) + + await new Promise((resolve, reject) => { + server.listen() + + server.addEventListener('listening', () => { + resolve() + }) + }) + } + + async close (): Promise { + if (this.server == null) { + return + } + + this.log('closing connections') + await Promise.all( + this.connections.map(async conn => { + await this.attemptClose(conn) + }) + ) + + this.log('stopping server') + this.server.close() + } + + /** + * Attempts to close the given maConn. If a failure occurs, it will be logged + */ + private async attemptClose (conn: Connection): Promise { + try { + await conn.close() + } catch (err) { + this.log.error('an error occurred closing the connection', err) + } + } +} + export default function createListener (components: WebTransportListenerComponents, options: WebTransportListenerInit): Listener { - throw new Error('Only supported in browsers') + return new WebTransportListener(components, options) } diff --git a/packages/transport-webtransport/src/utils/generate-certificates.ts b/packages/transport-webtransport/src/utils/generate-certificates.ts index 57d3d2692a..fa0fce97b4 100644 --- a/packages/transport-webtransport/src/utils/generate-certificates.ts +++ b/packages/transport-webtransport/src/utils/generate-certificates.ts @@ -1,11 +1,64 @@ +import * as x509 from '@peculiar/x509' +import { sha256 } from 'multiformats/hashes/sha2' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import type { WebTransportCertificate } from '../../src/index.js' +const ONE_DAY_MS = 86400000 + export interface GenerateWebTransportCertificateOptions { days: number start?: Date extensions?: any[] } +x509.cryptoProvider.set(globalThis.crypto) + +async function generateWebTransportCertificate (keyPair: CryptoKeyPair, options: GenerateWebTransportCertificateOptions): Promise { + const notBefore = options.start ?? new Date() + notBefore.setMilliseconds(0) + const notAfter = new Date(notBefore.getTime() + (options.days * ONE_DAY_MS)) + notAfter.setMilliseconds(0) + + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + serialNumber: (BigInt(Math.random().toString().replace('.', '')) * 100000n).toString(16), + name: '127.0.0.1', + notBefore, + notAfter, + signingAlgorithm: { + name: 'ECDSA' + }, + keys: keyPair, + extensions: [ + new x509.BasicConstraintsExtension(true), + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.nonRepudiation | x509.KeyUsageFlags.keyEncipherment | x509.KeyUsageFlags.dataEncipherment | x509.KeyUsageFlags.keyCertSign, false) + // new x509.SubjectAlternativeNameExtension([ + // { type: 'ip', value: '127.0.0.1' } + // ]) + ] + }) + + const exported = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey) + const privateKeyPem = [ + '-----BEGIN PRIVATE KEY-----', + ...uint8ArrayToString(new Uint8Array(exported), 'base64pad').split(/(.{64})/).filter(Boolean), + '-----END PRIVATE KEY-----' + ].join('\n') + + return { + privateKey: privateKeyPem, + pem: cert.toString('pem'), + hash: await sha256.digest(new Uint8Array(cert.rawData)), + secret: 'super-secret-shhhhhh' + } +} + export async function generateWebTransportCertificates (options: GenerateWebTransportCertificateOptions[] = []): Promise { - throw new Error('Not implemented') + const keyPair = await crypto.subtle.generateKey({ + name: 'ECDSA', + namedCurve: 'P-256' + }, true, ['sign', 'verify']) + + return Promise.all( + options.map(async opts => generateWebTransportCertificate(keyPair, opts)) + ) } diff --git a/packages/transport-webtransport/src/webtransport.ts b/packages/transport-webtransport/src/webtransport.ts index af19fed0e0..5d83b49b94 100644 --- a/packages/transport-webtransport/src/webtransport.ts +++ b/packages/transport-webtransport/src/webtransport.ts @@ -1,17 +1,3 @@ -export default class WebTransport { - constructor (url: string | URL, options?: WebTransportOptions) { - throw new Error('Only supported in browsers') - } +import { WebTransport } from '@fails-components/webtransport' - close (): void { - throw new Error('Only supported in browsers') - } - - async createBidirectionalStream (): Promise { - throw new Error('Only supported in browsers') - } - - public closed = Promise.reject(new Error('Only supported in browsers')) - public ready = Promise.reject(new Error('Only supported in browsers')) - public incomingBidirectionalStreams: ReadableStream -} +export default WebTransport diff --git a/packages/transport-webtransport/test/compliance.node.ts b/packages/transport-webtransport/test/compliance.node.ts new file mode 100644 index 0000000000..56da5e1080 --- /dev/null +++ b/packages/transport-webtransport/test/compliance.node.ts @@ -0,0 +1,70 @@ +import tests from '@libp2p/interface-compliance-tests/transport' +import { defaultLogger } from '@libp2p/logger' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import { multiaddr } from '@multiformats/multiaddr' +import { base64url } from 'multiformats/bases/base64' +import sinon from 'sinon' +import { webTransport, type WebTransportComponents } from '../src/index.js' +import { generateWebTransportCertificates } from '../src/utils/generate-certificates.js' + +describe('interface-transport compliance', () => { + tests({ + async setup () { + const components: WebTransportComponents = { + peerId: await createEd25519PeerId(), + logger: defaultLogger() + } + + const certificates = await generateWebTransportCertificates([{ + // can be max 14 days according to the spec + days: 13 + }, { + days: 13, + // start in 12 days time + start: new Date(Date.now() + (86400000 * 12)) + }]) + + const certhash1 = base64url.encode(certificates[0].hash.bytes) + const certhash2 = base64url.encode(certificates[0].hash.bytes) + + const transport = webTransport({ + certificates + })(components) + const listenAddrs = [ + multiaddr('/ip4/127.0.0.1/udp/9091/quic-v1/webtransport'), + multiaddr('/ip4/127.0.0.1/udp/9092/quic-v1/webtransport'), + multiaddr('/ip4/127.0.0.1/udp/9093/quic-v1/webtransport'), + multiaddr('/ip6/::/udp/9094/quic-v1/webtransport') + ] + const dialAddrs = [ + multiaddr(`/ip4/127.0.0.1/udp/9091/quic-v1/webtransport/certhash/${certhash1}/certhash/${certhash2}/p2p/${components.peerId.toString()}`), + multiaddr(`/ip4/127.0.0.1/udp/9092/quic-v1/webtransport/certhash/${certhash1}/certhash/${certhash2}/p2p/${components.peerId.toString()}`), + multiaddr(`/ip4/127.0.0.1/udp/9093/quic-v1/webtransport/certhash/${certhash1}/certhash/${certhash2}/p2p/${components.peerId.toString()}`), + multiaddr(`/ip6/::/udp/9094/quic-v1/webtransport/certhash/${certhash1}/certhash/${certhash2}/p2p/${components.peerId.toString()}`) + ] + + // Used by the dial tests to simulate a delayed connect + const connector = { + delay (delayMs: number) { + // @ts-expect-error method is not part of transport interface + const authenticateWebTransport = transport.authenticateWebTransport.bind(transport) + + // @ts-expect-error method is not part of transport interface + sinon.replace(transport, 'authenticateWebTransport', async (wt: WebTransport, localPeer: PeerId, remotePeer: PeerId, certhashes: Array>, signal?: AbortSignal) => { + await new Promise((resolve) => { + setTimeout(() => { resolve() }, delayMs) + }) + + return authenticateWebTransport(wt, localPeer, remotePeer, certhashes, signal) + }) + }, + restore () { + sinon.restore() + } + } + + return { transport, listenAddrs, dialAddrs, connector } + }, + async teardown () {} + }) +}) diff --git a/packages/transport-webtransport/test/node.ts b/packages/transport-webtransport/test/node.ts new file mode 100644 index 0000000000..18073fb78d --- /dev/null +++ b/packages/transport-webtransport/test/node.ts @@ -0,0 +1 @@ +import './compliance.node.js' diff --git a/packages/transport-webtransport/test/browser.ts b/packages/transport-webtransport/test/webtransport.spec.ts similarity index 100% rename from packages/transport-webtransport/test/browser.ts rename to packages/transport-webtransport/test/webtransport.spec.ts