From ca38ae1665fad72851204417138c2f895446870e Mon Sep 17 00:00:00 2001 From: Alex Potsides Date: Fri, 20 Sep 2024 10:45:23 +0200 Subject: [PATCH] feat: add WebRTC private to private example (#173) Restores old example of WebRTC between two browsers --- .github/workflows/ci.yml | 2 + .../.github/pull_request_template.md | 17 ++ .../.github/workflows/sync.yml | 19 ++ .../README.md | 95 ++++++++++ .../index.html | 50 +++++ .../index.js | 173 ++++++++++++++++++ .../package.json | 30 +++ .../relay.js | 37 ++++ .../test/index.spec.js | 128 +++++++++++++ .../vite.config.js | 11 ++ 10 files changed, 562 insertions(+) create mode 100644 examples/js-libp2p-example-webrtc-private-to-private/.github/pull_request_template.md create mode 100644 examples/js-libp2p-example-webrtc-private-to-private/.github/workflows/sync.yml create mode 100644 examples/js-libp2p-example-webrtc-private-to-private/README.md create mode 100644 examples/js-libp2p-example-webrtc-private-to-private/index.html create mode 100644 examples/js-libp2p-example-webrtc-private-to-private/index.js create mode 100644 examples/js-libp2p-example-webrtc-private-to-private/package.json create mode 100644 examples/js-libp2p-example-webrtc-private-to-private/relay.js create mode 100644 examples/js-libp2p-example-webrtc-private-to-private/test/index.spec.js create mode 100644 examples/js-libp2p-example-webrtc-private-to-private/vite.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bc1547..7226a32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: - js-libp2p-example-custom-protocols - js-libp2p-example-delegated-routing - js-libp2p-example-discovery-mechanisms + - js-libp2p-example-webrtc-private-to-private defaults: run: working-directory: examples/${{ matrix.project }} @@ -81,6 +82,7 @@ jobs: - js-libp2p-example-custom-protocols - js-libp2p-example-delegated-routing - js-libp2p-example-discovery-mechanisms + - js-libp2p-example-webrtc-private-to-private steps: - uses: convictional/trigger-workflow-and-wait@f69fa9eedd3c62a599220f4d5745230e237904be with: diff --git a/examples/js-libp2p-example-webrtc-private-to-private/.github/pull_request_template.md b/examples/js-libp2p-example-webrtc-private-to-private/.github/pull_request_template.md new file mode 100644 index 0000000..accc1b8 --- /dev/null +++ b/examples/js-libp2p-example-webrtc-private-to-private/.github/pull_request_template.md @@ -0,0 +1,17 @@ +# ⚠️ IMPORTANT ⚠️ + +# Please do not create a Pull Request for this repository + +The contents of this repository are automatically synced from the parent [js-libp2p Examples Project](https://github.com/libp2p/js-libp2p-examples) so any changes made to the standalone repository will be lost after the next sync. + +Please open a PR against [js-libp2p Examples](https://github.com/libp2p/js-libp2p-examples) instead. + +## Contributing + +Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. + +1. Fork the [js-libp2p Examples Project](https://github.com/libp2p/js-libp2p-examples) +2. Create your Feature Branch (`git checkout -b feature/amazing-example`) +3. Commit your Changes (`git commit -a -m 'feat: add some amazing example'`) +4. Push to the Branch (`git push origin feature/amazing-example`) +5. Open a Pull Request diff --git a/examples/js-libp2p-example-webrtc-private-to-private/.github/workflows/sync.yml b/examples/js-libp2p-example-webrtc-private-to-private/.github/workflows/sync.yml new file mode 100644 index 0000000..78f6c8d --- /dev/null +++ b/examples/js-libp2p-example-webrtc-private-to-private/.github/workflows/sync.yml @@ -0,0 +1,19 @@ +name: pull + +on: + workflow_dispatch + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Pull from another repository + uses: ipfs-examples/actions-pull-directory-from-repo@main + with: + source-repo: libp2p/js-libp2p-examples + source-folder-path: examples/${{ github.event.repository.name }} + source-branch: main + target-branch: main + git-username: github-actions + git-email: github-actions@github.com diff --git a/examples/js-libp2p-example-webrtc-private-to-private/README.md b/examples/js-libp2p-example-webrtc-private-to-private/README.md new file mode 100644 index 0000000..c4fc719 --- /dev/null +++ b/examples/js-libp2p-example-webrtc-private-to-private/README.md @@ -0,0 +1,95 @@ +# @libp2p/example-webrtc-private-to-private + +[![libp2p.io](https://img.shields.io/badge/project-libp2p-yellow.svg?style=flat-square)](http://libp2p.io/) +[![Discuss](https://img.shields.io/discourse/https/discuss.libp2p.io/posts.svg?style=flat-square)](https://discuss.libp2p.io) +[![codecov](https://img.shields.io/codecov/c/github/libp2p/js-libp2p-examples.svg?style=flat-square)](https://codecov.io/gh/libp2p/js-libp2p-examples) +[![CI](https://img.shields.io/github/actions/workflow/status/libp2p/js-libp2p-examples/ci.yml?branch=main\&style=flat-square)](https://github.com/libp2p/js-libp2p-examples/actions/workflows/ci.yml?query=branch%3Amain) + +In libp2p terms a "private" node is one behind a [NAT firewall](https://en.wikipedia.org/wiki/Network_address_translation) that prevents it from being dialed externally. + +This could be a browser, a node.js process or something else. + +Nodes that support the [libp2p WebRTC transport](https://github.com/libp2p/specs/blob/master/webrtc/webrtc.md) such as browsers can by dialed via this method even if they are behind a NAT. + +When establishing a WebRTC connection, the two browsers must first exchange a series of messages that establish the required capabilities of the nodes (we only require RTC data channels, no video or audio), and their internet-facing addresses/ports. + +This is referred to as the "SDP handshake". The WebRTC spec requires this to take place out-of-band, so libp2p performs the handshake via a [Circuit Relay Server](https://docs.libp2p.io/concepts/nat/circuit-relay/) - this is another network node that has made some resources available for the good of the network. + +When two browsers dial each other the following steps occur: + +1. The listener makes a reservation on a relay with a free slot +2. The dialer obtains the listener's relay address +3. The dialer dials the relay and specifies the listeners PeerId as part of the Circuit Relay HOP protocol +4. The relay opens a stream on the listener as part of the Circuit Relay STOP protocol +5. A virtual connection is created between the dialer and the listener via the relay +6. The dialer opens a stream on the virtual connection to perform the SDP handshake +7. SDP messages are exchanged +8. A direct WebRTC connection is opened between the two browsers + +At this point the browsers are directly connected and the relay plays no further part. + +## Running the Example + +### Build the `@libp2p/example-webrtc-private-to-private` package + +Build example by calling `npm i && npm run build` in the repository root. + +### Running the Relay Server + +For browsers to communicate, we first need to run a relay server: + +```shell +npm run relay +``` + +The [multiaddress](https://docs.libp2p.io/concepts/fundamentals/addressing/) the relay is listening on will be printed to the console. Copy one of them to your clipboard. + +### Running the Clients + +In a separate console tab, start the web server: + +```shell +npm start +``` + +A browser window will automatically open. Let's call this `Browser A`. + +Using the copied multiaddrs from the relay server, paste it into the `Remote MultiAddress` input and click the `Connect` button. +`Browser A` is now connected to the relay server. + +Copy the multiaddr located after the `Listening on` message. + +Now open a second tab with the url `http://localhost:5173/`, perhaps in a different browser or a private window. Let's call this `Browser B`. + +Using the copied multiaddress from `Listening on` section in `Browser A`, paste it into the `Remote MultiAddress` input and click the `Connect` button. + +The peers are now connected to each other. + +Enter a message and click the `Send` button in either/both browsers and see the echo'd messages. + +The output should look like: + +`Browser A` +```text +Dialing '/ip4/127.0.0.1/tcp/57708/ws/p2p/12D3KooWRqAUEzPwKMoGstpfJVqr3aoinwKVPu4DLo9nQncbnuLk' +Listening on /ip4/127.0.0.1/tcp/57708/ws/p2p/12D3KooWRqAUEzPwKMoGstpfJVqr3aoinwKVPu4DLo9nQncbnuLk/p2p-circuit/p2p/12D3KooW9wFiWFELqGJTbzEwtByXsPiHJdHB8n7Kin71VMYyERmC/p2p-circuit/webrtc/p2p/12D3KooW9wFiWFELqGJTbzEwtByXsPiHJdHB8n7Kin71VMYyERmC +Dialing '/ip4/127.0.0.1/tcp/57708/ws/p2p/12D3KooWRqAUEzPwKMoGstpfJVqr3aoinwKVPu4DLo9nQncbnuLk/p2p-circuit/p2p/12D3KooWBZyVLJfQkofqLK4op9TPkHuUumCZt1ybQrPvNm7TVQV9/p2p-circuit/webrtc/p2p/12D3KooWBZyVLJfQkofqLK4op9TPkHuUumCZt1ybQrPvNm7TVQV9' +Sending message 'helloa' +Received message 'helloa' +Received message 'hellob' +``` + +`Browser B` +```text +Dialing '/ip4/127.0.0.1/tcp/57708/ws/p2p/12D3KooWRqAUEzPwKMoGstpfJVqr3aoinwKVPu4DLo9nQncbnuLk/p2p-circuit/p2p/12D3KooW9wFiWFELqGJTbzEwtByXsPiHJdHB8n7Kin71VMYyERmC/p2p-circuit/webrtc/p2p/12D3KooW9wFiWFELqGJTbzEwtByXsPiHJdHB8n7Kin71VMYyERmC' +Listening on /ip4/127.0.0.1/tcp/57708/ws/p2p/12D3KooWRqAUEzPwKMoGstpfJVqr3aoinwKVPu4DLo9nQncbnuLk/p2p-circuit/p2p/12D3KooWBZyVLJfQkofqLK4op9TPkHuUumCZt1ybQrPvNm7TVQV9/p2p-circuit/webrtc/p2p/12D3KooWBZyVLJfQkofqLK4op9TPkHuUumCZt1ybQrPvNm7TVQV9 +Received message 'helloa' +Sending message 'hellob' +Received message 'hellob' +``` + +## Next steps + +The WebRTC transport is not limited to browsers. + +Why don't you try to create a Node.js version of the [browser peer script](./index.js)? diff --git a/examples/js-libp2p-example-webrtc-private-to-private/index.html b/examples/js-libp2p-example-webrtc-private-to-private/index.html new file mode 100644 index 0000000..241d0ea --- /dev/null +++ b/examples/js-libp2p-example-webrtc-private-to-private/index.html @@ -0,0 +1,50 @@ + + + + + + js-libp2p WebRTC + + + +
+
+ + + +
+
+ + + +
+
+

Active Connections:

+
    +
    +
    +

    Listening addresses:

    +
      +
      +

      Output:

      +
      +
      + + + diff --git a/examples/js-libp2p-example-webrtc-private-to-private/index.js b/examples/js-libp2p-example-webrtc-private-to-private/index.js new file mode 100644 index 0000000..102f813 --- /dev/null +++ b/examples/js-libp2p-example-webrtc-private-to-private/index.js @@ -0,0 +1,173 @@ +import { noise } from '@chainsafe/libp2p-noise' +import { yamux } from '@chainsafe/libp2p-yamux' +import { circuitRelayTransport } from '@libp2p/circuit-relay-v2' +import { identify, identifyPush } from '@libp2p/identify' +import { ping } from '@libp2p/ping' +import { webRTC } from '@libp2p/webrtc' +import { webSockets } from '@libp2p/websockets' +import * as filters from '@libp2p/websockets/filters' +import { multiaddr, protocols } from '@multiformats/multiaddr' +import { byteStream } from 'it-byte-stream' +import { createLibp2p } from 'libp2p' +import { fromString, toString } from 'uint8arrays' + +const WEBRTC_CODE = protocols('webrtc').code + +const output = document.getElementById('output') +const sendSection = document.getElementById('send-section') +const appendOutput = (line) => { + const div = document.createElement('div') + div.appendChild(document.createTextNode(line)) + output.append(div) +} +const CHAT_PROTOCOL = '/libp2p/examples/chat/1.0.0' +let ma +let chatStream + +const node = await createLibp2p({ + addresses: { + listen: [ + '/webrtc' + ] + }, + transports: [ + webSockets({ + filter: filters.all + }), + webRTC(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ], + connectionEncrypters: [noise()], + streamMuxers: [yamux()], + connectionGater: { + denyDialMultiaddr: () => { + // by default we refuse to dial local addresses from the browser since they + // are usually sent by remote peers broadcasting undialable multiaddrs but + // here we are explicitly connecting to a local node so do not deny dialing + // any discovered address + return false + } + }, + services: { + identify: identify(), + identifyPush: identifyPush(), + ping: ping() + } +}) + +await node.start() + +function updateConnList () { + // Update connections list + const connListEls = node.getConnections() + .map((connection) => { + if (connection.remoteAddr.protoCodes().includes(WEBRTC_CODE)) { + ma = connection.remoteAddr + sendSection.style.display = 'block' + } + + const el = document.createElement('li') + el.textContent = connection.remoteAddr.toString() + return el + }) + document.getElementById('connections').replaceChildren(...connListEls) +} + +node.addEventListener('connection:open', (event) => { + updateConnList() +}) +node.addEventListener('connection:close', (event) => { + updateConnList() +}) + +node.addEventListener('self:peer:update', (event) => { + // Update multiaddrs list, only show WebRTC addresses + const multiaddrs = node.getMultiaddrs() + .filter(ma => isWebrtc(ma)) + .map((ma) => { + const el = document.createElement('li') + el.textContent = ma.toString() + return el + }) + document.getElementById('multiaddrs').replaceChildren(...multiaddrs) +}) + +node.handle(CHAT_PROTOCOL, async ({ stream }) => { + chatStream = byteStream(stream) + + while (true) { + const buf = await chatStream.read() + appendOutput(`Received message '${toString(buf.subarray())}'`) + } +}) + +const isWebrtc = (ma) => { + return ma.protoCodes().includes(WEBRTC_CODE) +} + +window.connect.onclick = async () => { + ma = multiaddr(window.peer.value) + appendOutput(`Dialing '${ma}'`) + + const signal = AbortSignal.timeout(5000) + + try { + if (isWebrtc(ma)) { + const rtt = await node.services.ping.ping(ma, { + signal + }) + appendOutput(`Connected to '${ma}'`) + appendOutput(`RTT to ${ma.getPeerId()} was ${rtt}ms`) + } else { + await node.dial(ma, { + signal + }) + appendOutput('Connected to relay') + } + } catch (err) { + if (signal.aborted) { + appendOutput(`Timed out connecting to '${ma}'`) + } else { + appendOutput(`Connecting to '${ma}' failed - ${err.message}`) + } + } +} + +window.send.onclick = async () => { + if (chatStream == null) { + appendOutput('Opening chat stream') + + const signal = AbortSignal.timeout(5000) + + try { + const stream = await node.dialProtocol(ma, CHAT_PROTOCOL, { + signal + }) + chatStream = byteStream(stream) + + Promise.resolve().then(async () => { + while (true) { + const buf = await chatStream.read() + appendOutput(`Received message '${toString(buf.subarray())}'`) + } + }) + } catch (err) { + if (signal.aborted) { + appendOutput('Timed out opening chat stream') + } else { + appendOutput(`Opening chat stream failed - ${err.message}`) + } + + return + } + } + + const message = window.message.value.toString().trim() + appendOutput(`Sending message '${message}'`) + chatStream.write(fromString(message)) + .catch(err => { + appendOutput(`Error sending message - ${err.message}`) + }) +} diff --git a/examples/js-libp2p-example-webrtc-private-to-private/package.json b/examples/js-libp2p-example-webrtc-private-to-private/package.json new file mode 100644 index 0000000..0bc385f --- /dev/null +++ b/examples/js-libp2p-example-webrtc-private-to-private/package.json @@ -0,0 +1,30 @@ +{ + "name": "@libp2p/example-webrtc-private-to-private", + "version": "1.0.0", + "description": "Connect a browser to another browser", + "type": "module", + "scripts": { + "start": "vite", + "build": "vite build", + "relay": "node relay.js", + "test:firefox": "npm run build && playwright test --browser=firefox test", + "test:chrome": "npm run build && playwright test test", + "test": "npm run build && test-browser-example test" + }, + "dependencies": { + "@chainsafe/libp2p-noise": "^16.0.0", + "@chainsafe/libp2p-yamux": "^7.0.0", + "@libp2p/circuit-relay-v2": "^2.0.0", + "@libp2p/identify": "^3.0.1", + "@libp2p/ping": "^2.0.1", + "@libp2p/webrtc": "^5.0.0", + "@libp2p/websockets": "^9.0.0", + "@multiformats/multiaddr": "^12.0.0", + "it-pushable": "^3.2.0", + "libp2p": "^2.0.0", + "vite": "^5.3.1" + }, + "devDependencies": { + "test-ipfs-example": "^1.0.0" + } +} diff --git a/examples/js-libp2p-example-webrtc-private-to-private/relay.js b/examples/js-libp2p-example-webrtc-private-to-private/relay.js new file mode 100644 index 0000000..059ba3d --- /dev/null +++ b/examples/js-libp2p-example-webrtc-private-to-private/relay.js @@ -0,0 +1,37 @@ +/* eslint-disable no-console */ + +import { noise } from '@chainsafe/libp2p-noise' +import { yamux } from '@chainsafe/libp2p-yamux' +import { circuitRelayServer } from '@libp2p/circuit-relay-v2' +import { identify } from '@libp2p/identify' +import { webSockets } from '@libp2p/websockets' +import * as filters from '@libp2p/websockets/filters' +import { createLibp2p } from 'libp2p' + +const server = await createLibp2p({ + addresses: { + listen: ['/ip4/127.0.0.1/tcp/0/ws'] + }, + transports: [ + webSockets({ + filter: filters.all + }) + ], + connectionEncrypters: [noise()], + streamMuxers: [yamux()], + services: { + identify: identify(), + relay: circuitRelayServer({ + // disable max reservations limit for demo purposes. in production you + // should leave this set to the default of 15 to prevent abuse of your + // node by network peers + reservations: { + maxReservations: Infinity + } + }) + } +}) + +console.info('The relay node is running and listening on the following multiaddrs:') +console.info('') +console.info(server.getMultiaddrs().map((ma) => ma.toString()).join('\n')) diff --git a/examples/js-libp2p-example-webrtc-private-to-private/test/index.spec.js b/examples/js-libp2p-example-webrtc-private-to-private/test/index.spec.js new file mode 100644 index 0000000..2e16bea --- /dev/null +++ b/examples/js-libp2p-example-webrtc-private-to-private/test/index.spec.js @@ -0,0 +1,128 @@ +/* eslint-disable no-console */ +import { noise } from '@chainsafe/libp2p-noise' +import { yamux } from '@chainsafe/libp2p-yamux' +import { circuitRelayServer } from '@libp2p/circuit-relay-v2' +import { identify } from '@libp2p/identify' +import { webSockets } from '@libp2p/websockets' +import * as filters from '@libp2p/websockets/filters' +import { createLibp2p } from 'libp2p' +import { setup, expect } from 'test-ipfs-example/browser' + +// Setup +const test = setup() + +// DOM +const connectBtn = '#connect' +const connectAddr = '#peer' +const messageInput = '#message' +const sendBtn = '#send' +const output = '#output' +const listeningAddresses = '#multiaddrs' + +let url + +// we spawn a js libp2p relay +async function spawnRelay () { + const relayNode = await createLibp2p({ + addresses: { + listen: ['/ip4/127.0.0.1/tcp/0/ws'] + }, + transports: [ + webSockets({ + filter: filters.all + }) + ], + connectionEncrypters: [noise()], + streamMuxers: [yamux()], + services: { + identify: identify(), + relay: circuitRelayServer() + } + }) + + const relayNodeAddr = relayNode.getMultiaddrs()[0].toString() + + return { relayNode, relayNodeAddr } +} + +test.describe('browser to browser example:', () => { + let relayNode + let relayNodeAddr + + // eslint-disable-next-line no-empty-pattern + test.beforeAll(async ({ servers }, testInfo) => { + testInfo.setTimeout(5 * 60_000) + const r = await spawnRelay() + relayNode = r.relayNode + relayNodeAddr = r.relayNodeAddr + url = servers[0].url + }, {}) + + test.afterAll(() => { + relayNode.stop() + }) + + test.beforeEach(async ({ page }) => { + await page.goto(url) + }) + + test('should connect to another browser peer and send a message', async ({ page: pageA, context }) => { + // load second page + const pageB = await context.newPage() + await pageB.goto(url) + + // connect the first page to the relay + const relayedAddressA = await dialRelay(pageA, relayNodeAddr) + + // dial first page from second page over relay + await dialPeerOverRelay(pageB, relayedAddressA) + + // stop the relay + await relayNode.stop() + + // send a message from a to b + await sendMessage(pageA, pageB, 'hello B from A') + + // send a message from b to a + await sendMessage(pageB, pageA, 'hello A from B') + }) +}) + +async function sendMessage (senderPage, recipientPage, message) { + // send the message to the peer over webRTC + await senderPage.fill(messageInput, message) + await senderPage.click(sendBtn) + + // check the message was sent + await expect(senderPage.locator(output)).toContainText(`Sending message '${message}'`) + // check the message was received + await expect(recipientPage.locator(output)).toContainText(`Received message '${message}'`) +} + +async function dialRelay (page, address) { + // add the go libp2p multiaddress to the input field and submit + await page.fill(connectAddr, address) + await page.click(connectBtn) + + const outputLocator = page.locator(output) + await expect(outputLocator).toContainText(`Dialing '${address}'`) + await expect(outputLocator).toContainText('Connected to relay') + + const multiaddrsLocator = page.locator(listeningAddresses) + await expect(multiaddrsLocator).toHaveText(/webrtc/) + + const multiaddrs = await page.textContent(listeningAddresses) + const addr = multiaddrs.split(address).filter(str => str.includes('webrtc')).pop() + + return address + addr +} + +async function dialPeerOverRelay (page, address) { + // add the go libp2p multiaddr to the input field and submit + await page.fill(connectAddr, address) + await page.click(connectBtn) + + const outputLocator = page.locator(output) + await expect(outputLocator).toContainText(`Dialing '${address}'`) + await expect(outputLocator).toContainText(`Connected to '${address}'`) +} diff --git a/examples/js-libp2p-example-webrtc-private-to-private/vite.config.js b/examples/js-libp2p-example-webrtc-private-to-private/vite.config.js new file mode 100644 index 0000000..8de4e24 --- /dev/null +++ b/examples/js-libp2p-example-webrtc-private-to-private/vite.config.js @@ -0,0 +1,11 @@ +export default { + build: { + target: 'es2022' + }, + optimizeDeps: { + esbuildOptions: { target: 'es2022', supported: { bigint: true } } + }, + server: { + open: true + } +}