From 804c14fc358cfafa555383c556f272b935d5c7cd Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Sun, 24 May 2026 17:32:05 +0700 Subject: [PATCH] Improve P2P stats peer locations and Kubo display (#1139) * feat(p2p stats): plot peers by ip location * fix(p2p stats): show own node map marker * feat(p2p stats): support full node peers * fix(p2p stats): clean up kubo stats * fix(p2p stats): address review feedback --- .../__tests__/p2p-stats-settings.test.tsx | 399 +++++++++++++++++ .../__tests__/peer-world-map.test.tsx | 45 +- .../p2p-stats-settings.module.css | 12 +- .../p2p-stats-settings/p2p-stats-settings.tsx | 418 ++++++++++++++++-- .../p2p-stats-settings/peer-world-map.tsx | 26 +- src/lib/__tests__/p2p-runtime.test.ts | 11 +- src/lib/__tests__/peer-geo.test.ts | 91 +++- src/lib/p2p-runtime.ts | 6 +- src/lib/peer-geo.ts | 124 +++++- 9 files changed, 1051 insertions(+), 81 deletions(-) diff --git a/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx b/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx index de8ae783..9d4c205a 100644 --- a/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx +++ b/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx @@ -45,6 +45,8 @@ const renderSettings = async (isElectron = false) => { const getStatRows = () => new Map(Array.from(container.querySelectorAll('tr')).map((row) => [row.children.item(0)?.textContent ?? '', row.children.item(1)?.textContent ?? ''])); +const getMarkerByTitle = (title: string) => Array.from(container.querySelectorAll('svg rect')).find((rect) => rect.querySelector('title')?.textContent === title); + describe('P2PStatsSettings', () => { beforeEach(() => { vi.clearAllMocks(); @@ -183,6 +185,141 @@ describe('P2PStatsSettings', () => { expect(container.textContent).not.toContain('topic subscribers'); }); + it('places peer map markers from city-level IP locations', async () => { + const fetchMock = vi.fn(async (url: string | URL | Request) => { + const requestUrl = String(url); + if (requestUrl === 'https://free.freeipapi.com/api/json/91.234.199.189') { + return { + ok: true, + json: async () => ({ + cityName: 'Haarlem', + countryCode: 'NL', + ipAddress: '91.234.199.189', + latitude: 52.3874, + longitude: 4.64622, + regionName: 'North Holland', + }), + }; + } + return { + ok: true, + json: async () => ({ country: 'US', ip: '147.75.84.175' }), + }; + }); + vi.stubGlobal('fetch', fetchMock); + testState.account = { + ...testState.account, + pkcOptions: { + libp2pJsClientsOptions: [{ key: 'libp2pjs' }], + }, + pkc: { + clients: { + libp2pJsClients: { + libp2pjs: { + key: 'libp2pjs', + _helia: { + libp2p: { + getConnections: () => [ + { + remoteAddr: { toString: () => '/ip4/91.234.199.189/tcp/4001/ws/p2p/peer-geo' }, + remotePeer: { toString: () => 'peer-geo' }, + }, + ], + getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'], + getPeers: () => ['peer-geo'], + peerId: { toString: () => 'self-peer' }, + }, + }, + }, + }, + }, + }, + }; + + await renderSettings(false); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const marker = getMarkerByTitle('peer-geo - Haarlem, North Holland, NL'); + expect(marker).not.toBeNull(); + expect(marker?.getAttribute('height')).toBe('3'); + expect(marker?.getAttribute('width')).toBe('3'); + expect(Number(marker?.getAttribute('x'))).toBeCloseTo(183.15, 1); + expect(Number(marker?.getAttribute('y'))).toBeCloseTo(36.11, 1); + expect(fetchMock).toHaveBeenCalledWith('https://free.freeipapi.com/api/json/91.234.199.189', expect.objectContaining({ signal: expect.any(AbortSignal) })); + }); + + it('shows the own IP flag and a red precise map marker when leeching', async () => { + const fetchMock = vi.fn(async (url: string | URL | Request) => { + const requestUrl = String(url); + if (requestUrl === 'https://api.country.is/117.2.120.113') { + return { + ok: false, + json: async () => ({}), + }; + } + if (requestUrl === 'https://free.freeipapi.com/api/json/117.2.120.113') { + return { + ok: true, + json: async () => ({ + cityName: 'Da Nang', + countryCode: 'VN', + ipAddress: '117.2.120.113', + latitude: 16.0678, + longitude: 108.221, + regionName: 'Da Nang City', + }), + }; + } + return { + ok: true, + json: async () => ({ country: 'US', ip: '147.75.84.175' }), + }; + }); + vi.stubGlobal('fetch', fetchMock); + testState.account = { + ...testState.account, + pkcOptions: { + libp2pJsClientsOptions: [{ key: 'libp2pjs' }], + }, + pkc: { + clients: { + libp2pJsClients: { + libp2pjs: { + key: 'libp2pjs', + _helia: { + libp2p: { + getConnections: () => [], + getMultiaddrs: () => ['/ip4/117.2.120.113/tcp/4001/ws'], + getPeers: () => [], + peerId: { toString: () => 'self-peer' }, + }, + }, + }, + }, + }, + }, + }; + + await renderSettings(false); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const rows = getStatRows(); + const yourIpRow = Array.from(container.querySelectorAll('tr')).find((row) => row.textContent?.includes('Your IP')); + const marker = getMarkerByTitle('Your node - Da Nang, Da Nang City, VN'); + expect(container.textContent).toContain('Leeching'); + expect(rows.get('Your IP')).toContain('117.2.120.113'); + expect(yourIpRow?.querySelector('[role="img"]')?.getAttribute('aria-label')).toBe('Vietnam'); + expect(marker?.getAttribute('data-peer-role')).toBe('leecher'); + expect(Number(marker?.getAttribute('x'))).toBeCloseTo(286.72, 1); + expect(Number(marker?.getAttribute('y'))).toBeCloseTo(72.43, 1); + }); + it('reads browser transfer counters from Helia bitswap ledgers', async () => { testState.account = { ...testState.account, @@ -354,4 +491,266 @@ describe('P2PStatsSettings', () => { expect(container.querySelector('a[href="https://github.com/bitsocialnet/bitsocial-seeder"]')).toBeNull(); expect(container.textContent).not.toContain('seed mode'); }); + + it('renders browser full-node RPC stats with seeding mode and leecher peer markers', async () => { + const fetchMock = vi.fn(async (url: string | URL | Request) => { + const requestUrl = String(url); + if (requestUrl === 'https://free.freeipapi.com/api/json/147.75.84.175') { + return { + ok: true, + json: async () => ({ + cityName: 'New York', + countryCode: 'US', + latitude: 40.7128, + longitude: -74.006, + regionName: 'New York', + }), + }; + } + if (requestUrl === 'https://free.freeipapi.com/api/json/91.234.199.189') { + return { + ok: true, + json: async () => ({ + cityName: 'Haarlem', + countryCode: 'NL', + latitude: 52.3874, + longitude: 4.64622, + regionName: 'North Holland', + }), + }; + } + if (requestUrl === 'https://free.freeipapi.com/api/json/117.2.120.113') { + return { + ok: true, + json: async () => ({ + cityName: 'Da Nang', + countryCode: 'VN', + latitude: 16.0678, + longitude: 108.221, + regionName: 'Da Nang City', + }), + }; + } + return { + ok: true, + json: async () => ({ country: 'US', ip: '147.75.84.175' }), + }; + }); + vi.stubGlobal('fetch', fetchMock); + testState.rpcSettings = { state: 'connected' }; + testState.account = { + ...testState.account, + pkcOptions: { + pkcRpcClientsOptions: ['ws://147.75.84.175:9138'], + }, + pkc: { + clients: { + pkcRpcClients: { + 'ws://147.75.84.175:9138': { + getPeers: vi.fn().mockResolvedValue({ + peers: [ + { + address: '/ip4/91.234.199.189/tcp/4001', + listenAddress: '/ip4/91.234.199.189/tcp/4001', + peerId: 'seed-peer', + }, + { + address: '/ip4/117.2.120.113/tcp/4001', + listenAddress: '', + peerId: 'leech-peer', + }, + ], + }), + getStats: vi.fn().mockResolvedValue({ + bandwidth: { TotalIn: 1024, TotalOut: 2048 }, + identity: { + AgentVersion: 'kubo/full-node', + ID: 'full-node-peer', + Addresses: ['/ip4/147.75.84.175/tcp/4001'], + }, + }), + state: 'connected', + }, + }, + }, + }, + }; + + await renderSettings(false); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const rows = getStatRows(); + const markers = Array.from(container.querySelectorAll('svg rect')); + const ownMarker = markers.find((marker) => marker.querySelector('title')?.textContent?.startsWith('Your node')); + const leecherMarker = markers.find((marker) => marker.querySelector('title')?.textContent?.startsWith('leech-peer')); + expect(rows.get('Mode')).toBe('Seeding'); + expect(rows.get('PKC RPC')).toBe('connected'); + expect(rows.get('Peer ID')).toBe('full-node-peer'); + expect(rows.get('Your IP')).toContain('147.75.84.175'); + expect(rows.get('Data received')).toBe('1.00 KB'); + expect(rows.get('Data sent')).toBe('2.00 KB'); + expect(container.textContent).toContain('seed-peer'); + expect(container.textContent).toContain('leech-peer'); + expect(container.textContent).toContain('Leeching'); + expect(container.querySelector('a[href="https://github.com/bitsocialnet/bitsocial-seeder"]')).toBeNull(); + expect(ownMarker?.getAttribute('data-peer-role')).toBe('seeder'); + expect(leecherMarker?.getAttribute('data-peer-role')).toBe('leecher'); + }); + + it('does not resolve full-node RPC hostnames through external DNS', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + testState.rpcSettings = { state: 'connected' }; + testState.account = { + ...testState.account, + pkcOptions: { + pkcRpcClientsOptions: ['ws://node.example:9138'], + }, + pkc: { + clients: { + pkcRpcClients: { + 'ws://node.example:9138': { + getPeers: vi.fn().mockResolvedValue({ peers: [] }), + getStats: vi.fn().mockResolvedValue({ + bandwidth: { TotalIn: 0, TotalOut: 0 }, + identity: { + ID: 'hostname-rpc-node', + }, + }), + state: 'connected', + }, + }, + }, + }, + }; + + await renderSettings(false); + await act(async () => Promise.resolve()); + + const rows = getStatRows(); + expect(rows.get('Mode')).toBe('Seeding'); + expect(rows.get('Peer ID')).toBe('hostname-rpc-node'); + expect(rows.get('Your IP')).toBe('unavailable'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('renders Electron Kubo stats as seeding with the node location on the map', async () => { + const fetchMock = vi.fn(async (url: string | URL | Request) => { + const requestUrl = String(url); + if (requestUrl === 'http://localhost:50019/api/v0/id') { + return { + ok: true, + text: async () => + JSON.stringify({ + Addresses: [ + '/dns4/77-168-54-121.example/tcp/4001/tls/ws/p2p/relay-peer/p2p-circuit/p2p/desktop-kubo-peer', + '/ip4/117.2.120.113/udp/4001/webrtc-direct/p2p/desktop-kubo-peer', + ], + AgentVersion: 'kubo/0.41.0', + ID: 'desktop-kubo-peer', + }), + }; + } + if (requestUrl === 'http://localhost:50019/api/v0/swarm/peers?direction=true&latency=true&streams=true') { + return { + ok: true, + text: async () => + JSON.stringify({ + Peers: [ + { + Addr: '/ip4/91.234.199.189/tcp/4001', + Direction: 'outbound', + ListenAddress: '/ip4/91.234.199.189/tcp/4001', + Peer: 'kubo-seeder', + }, + { + Addr: '/ip4/203.0.113.10/tcp/4001', + Direction: 'inbound', + ListenAddress: '', + Peer: 'kubo-leecher', + }, + ], + }), + }; + } + if (requestUrl === 'http://localhost:50019/api/v0/stats/bw') { + return { ok: true, text: async () => JSON.stringify({ RateIn: 12, RateOut: 34, TotalIn: 4096, TotalOut: 8192 }) }; + } + if (requestUrl === 'https://free.freeipapi.com/api/json/117.2.120.113') { + return { + ok: true, + json: async () => ({ + cityName: 'Da Nang', + countryCode: 'VN', + latitude: 16.0678, + longitude: 108.221, + regionName: 'Da Nang City', + }), + }; + } + if (requestUrl === 'https://free.freeipapi.com/api/json/91.234.199.189') { + return { + ok: true, + json: async () => ({ + cityName: 'Haarlem', + countryCode: 'NL', + latitude: 52.3874, + longitude: 4.64622, + regionName: 'North Holland', + }), + }; + } + return { + ok: true, + json: async () => ({ country: 'VN', ip: '117.2.120.113' }), + }; + }); + vi.stubGlobal('fetch', fetchMock); + testState.rpcSettings = { state: 'connected' }; + testState.account = { + ...testState.account, + pkcOptions: { + pkcRpcClientsOptions: ['ws://localhost:9138'], + }, + }; + + await renderSettings(true); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const rows = getStatRows(); + const markers = Array.from(container.querySelectorAll('svg rect')); + const ownMarker = markers.find((marker) => marker.querySelector('title')?.textContent?.startsWith('Your node')); + const leecherMarker = markers.find((marker) => marker.querySelector('title')?.textContent?.startsWith('kubo-leecher')); + expect(rows.get('Mode')).toBe('Seeding'); + expect(rows.get('Kubo RPC')).toBe('http://localhost:50019/api/v0'); + expect(rows.get('Peer ID')).toBe('desktop-kubo-peer'); + expect(rows.get('Your IP')).toContain('117.2.120.113'); + expect(rows.get('Your IP')).not.toContain('77.168.54.121'); + expect(rows.get('Data received')).toBe('4.00 KB'); + expect(rows.get('Data sent')).toBe('8.00 KB'); + expect(rows.has('PKC RPC')).toBe(false); + expect(rows.has('Agent')).toBe(false); + expect(rows.has('Repo size')).toBe(false); + expect(rows.has('Repo objects')).toBe(false); + expect(rows.has('Bitswap peers')).toBe(false); + expect(rows.has('Bitswap wantlist')).toBe(false); + expect(rows.has('Bandwidth in')).toBe(false); + expect(rows.has('Bandwidth out')).toBe(false); + expect(container.textContent).toContain('kubo-seeder'); + expect(container.textContent).toContain('kubo-leecher'); + expect(container.textContent).toContain('Leeching'); + expect(container.textContent).not.toContain('Listen addresses'); + expect(ownMarker?.getAttribute('data-peer-role')).toBe('seeder'); + expect(leecherMarker?.getAttribute('data-peer-role')).toBe('leecher'); + expect(fetchMock).not.toHaveBeenCalledWith('http://localhost:50019/api/v0/repo/stat', expect.anything()); + expect(fetchMock).not.toHaveBeenCalledWith('http://localhost:50019/api/v0/bitswap/stat', expect.anything()); + expect(fetchMock).not.toHaveBeenCalledWith('http://localhost:50019/api/v0/version', expect.anything()); + expect(fetchMock).not.toHaveBeenCalledWith('http://localhost:50019/api/v0/swarm/addrs/local', expect.anything()); + }); }); diff --git a/src/components/settings-modal/p2p-stats-settings/__tests__/peer-world-map.test.tsx b/src/components/settings-modal/p2p-stats-settings/__tests__/peer-world-map.test.tsx index 17953cb1..6f8da09b 100644 --- a/src/components/settings-modal/p2p-stats-settings/__tests__/peer-world-map.test.tsx +++ b/src/components/settings-modal/p2p-stats-settings/__tests__/peer-world-map.test.tsx @@ -24,16 +24,57 @@ describe('PeerWorldMap', () => { container.remove(); }); - it('renders the rasterized land backdrop and a marker for a placeable peer', () => { - render(createElement(PeerWorldMap, { peers: [{ address: '/ip4/8.8.8.8/tcp/4001', id: 'c1', peerId: 'peer-1' }] })); + it('renders the rasterized land backdrop and a marker from a resolved IP location', () => { + render( + createElement(PeerWorldMap, { + peers: [ + { + address: '/ip4/91.234.199.189/tcp/4001', + id: 'c1', + location: { countryCode: 'nl', label: 'Haarlem, North Holland, NL', lat: 52.3874, lon: 4.64622, source: 'geoip' }, + peerId: 'peer-1', + }, + ], + }), + ); const landPath = container.querySelector('svg path'); + const marker = container.querySelector('svg rect'); expect(landPath).not.toBeNull(); // The land mask decodes into a large multi-square path, not a handful of points. expect((landPath?.getAttribute('d') ?? '').length).toBeGreaterThan(1000); expect(container.querySelectorAll('svg rect')).toHaveLength(1); + expect(marker?.getAttribute('height')).toBe('3'); + expect(marker?.getAttribute('width')).toBe('3'); + expect(Number(marker?.getAttribute('x'))).toBeCloseTo(183.15, 1); + expect(Number(marker?.getAttribute('y'))).toBeCloseTo(36.11, 1); + expect(container.querySelector('svg rect title')?.textContent).toBe('peer-1 - Haarlem, North Holland, NL'); + }); + + it('falls back to the offline location estimate for a public peer without GeoIP data', () => { + render(createElement(PeerWorldMap, { peers: [{ address: '/ip4/8.8.8.8/tcp/4001', id: 'c1', peerId: 'peer-1' }] })); + expect(container.querySelectorAll('svg rect')).toHaveLength(1); expect(container.querySelector('svg rect title')?.textContent).toBe('peer-1'); }); + it('marks leecher locations for red map styling', () => { + render( + createElement(PeerWorldMap, { + peers: [ + { + address: '/ip4/117.2.120.113/tcp/4001', + id: 'self', + location: { countryCode: 'vn', label: 'Da Nang, Da Nang City, VN', lat: 16.0678, lon: 108.221, source: 'geoip' }, + peerId: 'Your node', + role: 'leecher', + }, + ], + }), + ); + const marker = container.querySelector('svg rect'); + expect(marker?.getAttribute('data-peer-role')).toBe('leecher'); + expect(container.querySelector('svg rect title')?.textContent).toBe('Your node - Da Nang, Da Nang City, VN'); + }); + it('renders nothing when no peer can be placed offline', () => { render(createElement(PeerWorldMap, { peers: [{ address: '/ip4/10.0.0.1/tcp/4001', id: 'c1', peerId: 'peer-1' }] })); expect(container.querySelector('svg')).toBeNull(); diff --git a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css index cc4d2c9d..89dc113f 100644 --- a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css +++ b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css @@ -55,6 +55,7 @@ } .connectedPeersSummary { + display: inline-block; cursor: pointer; padding: 1px 0; } @@ -86,7 +87,8 @@ } .connectionDirection, -.connectionStatus { +.connectionStatus, +.connectionRole { display: inline-flex; align-items: center; gap: 4px; @@ -117,6 +119,10 @@ background: #3fb950; } +.connectionRole[data-peer-role='leecher'] { + color: red; +} + .peerId { overflow: hidden; font-family: monospace; @@ -189,6 +195,10 @@ fill: #3fb950; } +.peerMarker[data-peer-role='leecher'] { + fill: red; +} + .peerWorldMapCaption { padding: 1px 4px 2px; font-size: 0.8em; diff --git a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx index b2276d94..737e0b0c 100644 --- a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx +++ b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx @@ -2,7 +2,17 @@ import { Fragment, memo, useEffect, useReducer } from 'react'; import { useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks'; import { useTranslation } from 'react-i18next'; import { getCountryFlagPosition, getCountryLabel, normalizeCountryCode } from '../../../lib/country-flags'; -import { fetchOwnIpCountryCode, fetchOwnPublicEndpoint, getApproximateCountryCode, getFirstPublicIpFromAddresses, type PublicEndpoint } from '../../../lib/peer-geo'; +import { + fetchIpMapLocation, + fetchOwnIpCountryCode, + fetchOwnPublicEndpoint, + fetchPeerMapLocation, + getApproximateCountryCode, + getFirstPublicIpFromAddresses, + isPrivateOrReservedIpv4, + type PeerMapLocation, + type PublicEndpoint, +} from '../../../lib/peer-geo'; import { getP2PRuntimeMode, type P2PRuntimeMode } from '../../../lib/p2p-runtime'; import PeerWorldMap from './peer-world-map'; import styles from './p2p-stats-settings.module.css'; @@ -17,16 +27,30 @@ type TextStatRow = { type ConnectedPeerEntry = { address: string; + countryCode?: string; direction?: string; id: string; + location?: PeerMapLocation; peerId: string; + role?: PeerConnectionRole; status?: string; transport: string; }; +type PeerConnectionRole = 'leecher' | 'seeder'; + +type PeerMapEntry = { + address: string; + id: string; + location?: PeerMapLocation; + peerId: string; + role?: PeerConnectionRole; +}; + type ConnectedPeersStatRow = { connectionCount: number; entries: ConnectedPeerEntry[]; + mapEntries?: PeerMapEntry[]; name: string; peerCount: number; type: 'connectedPeers'; @@ -98,6 +122,12 @@ type Libp2pAddressManagerShape = { type BrowserLibp2pShape = NonNullable['libp2p']>>; +type PkcRpcClientShape = { + getPeers?: () => unknown | Promise; + getStats?: () => unknown | Promise; + state?: string; +}; + type TransferStats = { downloadedBytes?: number; uploadedBytes?: number; @@ -141,7 +171,7 @@ const formatBytes = (value: unknown) => { return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unitIndex]}`; }; -const formatRate = (value: unknown) => `${formatBytes(value)}/s`; +const formatOptionalBytes = (value: unknown) => (getFiniteNumber(value) === undefined ? 'unknown' : formatBytes(value)); const getFirstObjectValue = (value?: Record) => (value ? Object.values(value)[0] : undefined); @@ -186,6 +216,46 @@ const toArray = (value: unknown): unknown[] => { return []; }; +const getRecordField = (record: unknown, fields: string[]) => { + if (!isRecord(record)) return undefined; + for (const field of fields) { + if (field in record) return record[field]; + } + return undefined; +}; + +const getStringField = (record: unknown, fields: string[], fallback = '') => { + const value = getRecordField(record, fields); + if (Array.isArray(value)) return getStringValue(value[0], fallback); + return getStringValue(value, fallback); +}; + +const getAddressValues = (value: unknown): string[] => { + const iterableValues = Array.isArray(value) ? value : toArray(value); + const values = iterableValues.length ? iterableValues : [value]; + return values.flatMap((entry) => { + const address = getStringValue(entry, ''); + return address ? [address] : []; + }); +}; + +const getNestedValue = (source: unknown, path: string[]) => { + let current = source; + for (const key of path) { + if (!isRecord(current) || !(key in current)) return undefined; + current = current[key]; + } + return current; +}; + +const getFirstNestedValue = (source: unknown, paths: string[][]) => { + for (const path of paths) { + const value = getNestedValue(source, path); + if (value !== undefined) return value; + } + return undefined; +}; + const getSafeArray = async (getValue?: () => unknown[] | Promise | undefined): Promise => { try { return toArray(getValue ? await getValue() : undefined); @@ -209,6 +279,14 @@ const getAddressManagerAddresses = async (libp2p?: BrowserLibp2pShape): Promise< ]; }; +const getFirstPkcRpcClient = (account?: AccountShape) => getFirstObjectValue(account?.pkc?.clients?.pkcRpcClients) as PkcRpcClientShape | undefined; + +const getPkcRpcUrls = (account?: AccountShape) => { + const optionUrls = Array.isArray(account?.pkcOptions?.pkcRpcClientsOptions) ? account.pkcOptions.pkcRpcClientsOptions : []; + const clientUrls = isRecord(account?.pkc?.clients?.pkcRpcClients) ? Object.keys(account.pkc.clients.pkcRpcClients) : []; + return [...new Set([...optionUrls, ...clientUrls].filter((url): url is string => typeof url === 'string' && url.trim().length > 0))]; +}; + const getByteLength = (value: unknown): number | undefined => { if (value === null || value === undefined) return undefined; if (typeof value === 'string') return new TextEncoder().encode(value).byteLength; @@ -463,6 +541,43 @@ const getBrowserMode = (client?: Libp2pClientShape) => { return hasSupportedAdd(client) && hasProviderPublishingRouter(client) ? 'Seeding' : 'Leeching'; }; +const getEndpointAddress = (ip: string) => (ip.includes(':') ? `/ip6/${ip}/tcp/0` : `/ip4/${ip}/tcp/0`); + +const isIpv4Address = (value: string) => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value); + +const isLikelyPublicIp = (value: string) => { + const ip = value.trim(); + if (!ip) return false; + if (isIpv4Address(ip)) return !isPrivateOrReservedIpv4(ip); + if (!ip.includes(':')) return false; + const normalized = ip.toLowerCase(); + return normalized !== '::1' && !normalized.startsWith('fe80:') && !normalized.startsWith('fc') && !normalized.startsWith('fd'); +}; + +const getHostnameFromUrl = (url: string) => { + try { + return new URL(url).hostname.replace(/^\[|\]$/g, ''); + } catch { + return undefined; + } +}; + +const resolveEndpointFromIp = async (ip: string, signal?: AbortSignal): Promise => { + if (!isLikelyPublicIp(ip)) return undefined; + const location = await fetchIpMapLocation(ip, signal); + return { + countryCode: location?.countryCode ?? getApproximateCountryCode(getEndpointAddress(ip)), + ip, + location, + }; +}; + +const resolveEndpointFromHost = async (hostname: string, signal?: AbortSignal): Promise => { + const normalizedHostname = hostname.trim().toLowerCase(); + if (!normalizedHostname || normalizedHostname === 'localhost') return undefined; + return isLikelyPublicIp(normalizedHostname) ? resolveEndpointFromIp(normalizedHostname, signal) : undefined; +}; + const getTransportLabel = (address: string) => { const normalizedAddress = address.toLowerCase(); let transport = 'Unknown transport'; @@ -511,27 +626,66 @@ const getBrowserConnectedPeersRow = (peers: unknown[], connections: unknown[]): }; }; -// Resolves the "Your IP" row from the node's own observed addresses. The shown IP -// is geolocated accurately (it is the user's own address, never a peer's) so the -// flag matches it, instead of the coarse continent guess used for connected peers. -// Falls back to a public-endpoint lookup when libp2p only knows local/private addresses. -const resolveOwnEndpoint = async (addresses: unknown[], signal?: AbortSignal): Promise => { - const ip = getFirstPublicIpFromAddresses(addresses); - if (ip) return { countryCode: await fetchOwnIpCountryCode(ip, signal), ip }; - return fetchOwnPublicEndpoint(signal); +const PEER_LIST_FIELDS = ['Peers', 'peers', 'connectedPeers', 'connections']; +const PEER_ADDRESS_FIELDS = ['Addr', 'address', 'addr', 'remoteAddr', 'multiaddr', 'multiaddrString']; +const PEER_ID_FIELDS = ['Peer', 'peer', 'peerId', 'id', 'remotePeer']; +const PEER_DIRECTION_FIELDS = ['Direction', 'direction']; +const PEER_STATUS_FIELDS = ['Status', 'status', 'state']; +const PEER_ROLE_FIELDS = ['role', 'Role', 'mode', 'Mode', 'connectionRole', 'connectionType']; +const PEER_LISTEN_ADDRESS_FIELDS = ['listenAddress', 'listenAddresses', 'ListenAddress', 'ListenAddresses']; + +const normalizePeerRecords = (peers: unknown): unknown[] => { + if (isRecord(peers)) { + for (const field of PEER_LIST_FIELDS) { + const value = peers[field]; + if (Array.isArray(value)) return value; + } + return Object.values(peers); + } + return toArray(peers); }; -const getElectronConnectedPeersRow = (peers: unknown): ConnectedPeersStatRow => { - const peerEntries = isRecord(peers) && Array.isArray(peers.Peers) ? peers.Peers : []; - const entries = peerEntries.map((peer) => { - const address = isRecord(peer) ? getStringValue(peer.Addr, 'address unavailable') : 'address unavailable'; - const peerId = isRecord(peer) ? getStringValue(peer.Peer) : 'unknown'; - const direction = isRecord(peer) ? getStringValue(peer.Direction, '') : undefined; +const getPeerAddress = (peer: unknown) => { + const address = getStringField(peer, PEER_ADDRESS_FIELDS, ''); + if (address) return address; + const listenAddress = getAddressValues(getRecordField(peer, PEER_LISTEN_ADDRESS_FIELDS))[0]; + return listenAddress || 'address unavailable'; +}; + +const normalizePeerRole = (value: unknown): PeerConnectionRole | undefined => { + const role = getStringValue(value, '').toLowerCase(); + if (!role) return undefined; + if (role.includes('leech') || role === 'client') return 'leecher'; + if (role.includes('seed') || role === 'server') return 'seeder'; + return undefined; +}; + +const getListenAddressRole = (peer: unknown): PeerConnectionRole | undefined => { + if (!isRecord(peer)) return undefined; + for (const field of PEER_LISTEN_ADDRESS_FIELDS) { + if (!(field in peer)) continue; + return getAddressValues(peer[field]).length > 0 ? 'seeder' : 'leecher'; + } + return undefined; +}; + +const getPeerConnectionRole = (peer: unknown) => normalizePeerRole(getRecordField(peer, PEER_ROLE_FIELDS)) ?? getListenAddressRole(peer); + +const getConnectedPeersRowFromRecords = (peers: unknown): ConnectedPeersStatRow => { + const peerRecords = normalizePeerRecords(peers); + const entries = peerRecords.map((peer, index) => { + const address = getPeerAddress(peer); + const peerId = getStringField(peer, PEER_ID_FIELDS, getStringValue(peer, 'unknown')); + const direction = getStringField(peer, PEER_DIRECTION_FIELDS, ''); + const status = getStringField(peer, PEER_STATUS_FIELDS, ''); + const fallbackId = `${peerId}-${address}-${direction}-${index}`; return { address, - direction, - id: `${peerId}-${address}-${direction ?? ''}`, + direction: direction || undefined, + id: fallbackId, peerId, + role: getPeerConnectionRole(peer), + status: status || undefined, transport: getTransportLabel(address), }; }); @@ -539,16 +693,94 @@ const getElectronConnectedPeersRow = (peers: unknown): ConnectedPeersStatRow => if (entry.peerId && entry.peerId !== 'unknown') ids.add(entry.peerId); return ids; }, new Set()); + const peerCount = getFiniteNumber(getRecordField(peers, ['peerCount', 'peersCount', 'PeerCount'])); + const connectionCount = getFiniteNumber(getRecordField(peers, ['connectionCount', 'connectionsCount', 'ConnectionCount'])); return { - connectionCount: entries.length, + connectionCount: connectionCount ?? entries.length, entries, name: 'Connected peers', - peerCount: peerIds.size || entries.length, + peerCount: peerCount ?? (peerIds.size || entries.length), type: 'connectedPeers', }; }; +const resolveConnectedPeerLocations = async (row: ConnectedPeersStatRow, signal?: AbortSignal): Promise => { + if (!row.entries.length) return row; + const lookups = new Map>(); + const entries = await Promise.all( + row.entries.map(async (entry) => { + let lookup = lookups.get(entry.address); + if (!lookup) { + lookup = fetchPeerMapLocation(entry.address, signal); + lookups.set(entry.address, lookup); + } + const location = await lookup; + if (!location) return entry; + return { + ...entry, + countryCode: location.countryCode ?? entry.countryCode, + location, + }; + }), + ); + return { ...row, entries }; +}; + +// Resolves the "Your IP" row from observed node addresses for browser/full-node +// paths. Electron Kubo uses resolveKuboOwnEndpoint below because its address list +// can include relay/circuit endpoints owned by other peers. +const resolveOwnEndpoint = async (addresses: unknown[], signal?: AbortSignal): Promise => { + const ip = getFirstPublicIpFromAddresses(addresses); + if (ip) { + const [countryCode, location] = await Promise.all([fetchOwnIpCountryCode(ip, signal), fetchIpMapLocation(ip, signal)]); + return { + countryCode: location?.countryCode ?? countryCode ?? getApproximateCountryCode(getEndpointAddress(ip)), + ip, + location, + }; + } + return fetchOwnPublicEndpoint(signal); +}; + +const resolveKuboOwnEndpoint = async (addresses: unknown[], signal?: AbortSignal): Promise => { + const endpoint = await fetchOwnPublicEndpoint(signal); + if (endpoint) return endpoint; + const directAddresses = addresses.filter((address) => !getStringValue(address, '').toLowerCase().includes('/p2p-circuit')); + return resolveOwnEndpoint(directAddresses.length ? directAddresses : addresses, signal); +}; + +const getOwnMapEntry = (endpoint: PublicEndpoint | undefined, mode: string): PeerMapEntry[] => { + if (!endpoint?.location) return []; + return [ + { + address: getEndpointAddress(endpoint.ip), + id: 'self-node-endpoint', + location: endpoint.location, + peerId: 'Your node', + role: mode === 'Leeching' ? 'leecher' : 'seeder', + }, + ]; +}; + +const getPeerMapEntries = (row: ConnectedPeersStatRow): PeerMapEntry[] => + row.entries.map((entry) => ({ + address: entry.address, + id: entry.id, + location: entry.location, + peerId: entry.peerId, + role: entry.role ?? 'seeder', + })); + +const getElectronConnectedPeersRow = (peers: unknown): ConnectedPeersStatRow => getConnectedPeersRowFromRecords(peers); + +const getAddressListFromRecord = (record: unknown) => + [ + ...getAddressValues(getRecordField(record, ['Addresses', 'addresses'])), + ...getAddressValues(getRecordField(record, ['listenAddress', 'listenAddresses', 'ListenAddress', 'ListenAddresses'])), + ...getAddressValues(getRecordField(record, ['multiaddr', 'multiaddrs', 'Multiaddrs'])), + ].filter(Boolean); + const getBrowserLibp2pStats = async (account?: AccountShape, signal?: AbortSignal): Promise => { const client = getFirstObjectValue(account?.pkc?.clients?.libp2pJsClients) as Libp2pClientShape | undefined; const libp2p = client?._helia?.libp2p; @@ -558,20 +790,112 @@ const getBrowserLibp2pStats = async (account?: AccountShape, signal?: AbortSigna getSafeArray(() => libp2p?.getMultiaddrs?.()), getAddressManagerAddresses(libp2p), ]); - const transferStats = await getBrowserTransferStats(client, connections); const localAddresses = connections.flatMap((connection) => { const localAddr = isRecord(connection) ? connection.localAddr : undefined; return localAddr ? [localAddr] : []; }); - const nodeEndpoint = await resolveOwnEndpoint([...multiaddrs, ...addressManagerAddresses, ...localAddresses], signal); + const connectedPeersRow = getBrowserConnectedPeersRow(peers, connections); + const mode = getBrowserMode(client); + const [transferStats, nodeEndpoint, connectedPeers] = await Promise.all([ + getBrowserTransferStats(client, connections), + resolveOwnEndpoint([...multiaddrs, ...addressManagerAddresses, ...localAddresses], signal), + resolveConnectedPeerLocations(connectedPeersRow, signal), + ]); + const connectedPeersWithMapEntries = { + ...connectedPeers, + mapEntries: [...getOwnMapEntry(nodeEndpoint, mode), ...getPeerMapEntries(connectedPeers)], + }; return [ - { name: 'Mode', value: getBrowserMode(client) }, + { name: 'Mode', value: mode }, { name: 'Peer ID', value: libp2p?.peerId?.toString() ?? 'unknown' }, nodeEndpoint ? { countryCode: nodeEndpoint.countryCode, ip: nodeEndpoint.ip, name: 'Your IP', type: 'nodeEndpoint' } : { name: 'Your IP', value: 'unavailable' }, { name: 'Data received', value: transferStats.downloadedBytes === undefined ? 'unknown' : formatBytes(transferStats.downloadedBytes) }, { name: 'Data sent', value: transferStats.uploadedBytes === undefined ? 'unknown' : formatBytes(transferStats.uploadedBytes) }, - getBrowserConnectedPeersRow(peers, connections), + connectedPeersWithMapEntries, + ]; +}; + +const getFullNodeRpcEndpoint = async (account?: AccountShape, signal?: AbortSignal): Promise => { + const hostnames = getPkcRpcUrls(account).flatMap((rpcUrl) => { + const hostname = getHostnameFromUrl(rpcUrl); + return hostname ? [hostname] : []; + }); + const hasRemoteHost = hostnames.some((hostname) => hostname !== 'localhost' && hostname !== '127.0.0.1' && hostname !== '::1'); + const endpoints = await Promise.all(hostnames.map((hostname) => resolveEndpointFromHost(hostname, signal))); + const endpoint = endpoints.find(Boolean); + if (endpoint) return endpoint; + return hasRemoteHost ? undefined : fetchOwnPublicEndpoint(signal); +}; + +const isImplementedRpcMethod = (method: unknown): method is () => unknown | Promise => { + if (typeof method !== 'function') return false; + const source = getFunctionSource(method); + return !source?.includes('not implemented'); +}; + +const callPkcRpcMethod = async (client: PkcRpcClientShape | undefined, methodName: 'getPeers' | 'getStats') => { + const method = client?.[methodName]; + if (!isImplementedRpcMethod(method)) return undefined; + try { + return await method.call(client); + } catch { + return undefined; + } +}; + +const getRpcIdentity = (stats: unknown) => getFirstNestedValue(stats, [['identity'], ['Identity'], ['id'], ['node']]); + +const getRpcPeerId = (stats: unknown) => { + const identity = getRpcIdentity(stats); + return getStringValue(getRecordField(identity, ['ID', 'id', 'PeerID', 'peerId']) ?? getRecordField(stats, ['ID', 'id', 'PeerID', 'peerId'])); +}; + +const getRpcAgent = (stats: unknown) => { + const identity = getRpcIdentity(stats); + return getStringValue(getRecordField(identity, ['AgentVersion', 'agentVersion', 'agent']) ?? getRecordField(stats, ['AgentVersion', 'agentVersion', 'agent'])); +}; + +const getRpcBandwidthValue = (stats: unknown, fields: string[][]) => getFirstNestedValue(stats, fields); + +const getFullNodeRpcStats = async (account?: AccountShape, rpcState?: string, signal?: AbortSignal): Promise => { + const rpcClient = getFirstPkcRpcClient(account); + const [stats, rpcPeers] = await Promise.all([callPkcRpcMethod(rpcClient, 'getStats'), callPkcRpcMethod(rpcClient, 'getPeers')]); + const identity = getRpcIdentity(stats); + const statsAddresses = [...getAddressListFromRecord(identity), ...getAddressListFromRecord(stats)]; + const peerRecords = rpcPeers ?? getFirstNestedValue(stats, [['peers'], ['Peers'], ['connectedPeers'], ['connections']]); + const connectedPeers = await resolveConnectedPeerLocations(getConnectedPeersRowFromRecords(peerRecords), signal); + const nodeEndpoint = statsAddresses.length ? await resolveOwnEndpoint(statsAddresses, signal) : await getFullNodeRpcEndpoint(account, signal); + const connectedPeersWithMapEntries = { + ...connectedPeers, + mapEntries: [...getOwnMapEntry(nodeEndpoint, 'Seeding'), ...getPeerMapEntries(connectedPeers)], + }; + const bandwidthIn = getRpcBandwidthValue(stats, [ + ['bandwidth', 'TotalIn'], + ['bandwidth', 'totalIn'], + ['Bandwidth', 'TotalIn'], + ['TotalIn'], + ['totalIn'], + ['downloadedBytes'], + ]); + const bandwidthOut = getRpcBandwidthValue(stats, [ + ['bandwidth', 'TotalOut'], + ['bandwidth', 'totalOut'], + ['Bandwidth', 'TotalOut'], + ['TotalOut'], + ['totalOut'], + ['uploadedBytes'], + ]); + + return [ + { name: 'Mode', value: 'Seeding' }, + { name: 'PKC RPC', value: rpcClient?.state ?? rpcState ?? 'unknown' }, + { name: 'Peer ID', value: getRpcPeerId(stats) }, + nodeEndpoint ? { countryCode: nodeEndpoint.countryCode, ip: nodeEndpoint.ip, name: 'Your IP', type: 'nodeEndpoint' } : { name: 'Your IP', value: 'unavailable' }, + ...(getRpcAgent(stats) !== 'unknown' ? [{ name: 'Agent', value: getRpcAgent(stats) } satisfies TextStatRow] : []), + { name: 'Data received', value: formatOptionalBytes(bandwidthIn) }, + { name: 'Data sent', value: formatOptionalBytes(bandwidthOut) }, + connectedPeersWithMapEntries, ]; }; @@ -589,34 +913,37 @@ const kuboPostJson = async (path: string, params?: Record => { - const [identity, version, peers, bandwidth, repo, bitswap] = await Promise.all([ +const getElectronKuboStats = async (signal?: AbortSignal): Promise => { + const [identity, peers, bandwidth] = await Promise.all([ kuboPostJson('id', undefined, signal), - kuboPostJson('version', undefined, signal), kuboPostJson('swarm/peers', { direction: true, latency: true, streams: true }, signal), kuboPostJson('stats/bw', undefined, signal), - kuboPostJson('repo/stat', undefined, signal), - kuboPostJson('bitswap/stat', undefined, signal), ]); + const peerId = getStringValue(identity.ID, 'unknown'); + const [nodeEndpoint, connectedPeers] = await Promise.all([ + resolveKuboOwnEndpoint(getAddressListFromRecord(identity), signal), + resolveConnectedPeerLocations(getElectronConnectedPeersRow(peers), signal), + ]); + const connectedPeersWithMapEntries = { + ...connectedPeers, + mapEntries: [...getOwnMapEntry(nodeEndpoint, 'Seeding'), ...getPeerMapEntries(connectedPeers)], + }; return [ - { name: 'Mode', value: 'Desktop Kubo' }, - { name: 'PKC RPC', value: rpcState ?? 'unknown' }, - { name: 'Peer ID', value: identity.ID ?? 'unknown' }, - { name: 'Agent', value: identity.AgentVersion ?? version.Version ?? 'unknown' }, - { name: 'Bandwidth in', value: `${formatBytes(bandwidth.TotalIn)} total, ${formatRate(bandwidth.RateIn)}` }, - { name: 'Bandwidth out', value: `${formatBytes(bandwidth.TotalOut)} total, ${formatRate(bandwidth.RateOut)}` }, - { name: 'Repo size', value: formatBytes(repo.RepoSize) }, - { name: 'Repo objects', value: String(repo.NumObjects ?? 'unknown') }, - { name: 'Bitswap peers', value: formatCount(Array.isArray(bitswap.Peers) ? bitswap.Peers.length : 0, 'peer') }, - { name: 'Bitswap wantlist', value: formatCount(Array.isArray(bitswap.Wantlist) ? bitswap.Wantlist.length : 0, 'item') }, - getElectronConnectedPeersRow(peers), + { name: 'Mode', value: 'Seeding' }, + { name: 'Kubo RPC', value: KUBO_API_URL }, + { name: 'Peer ID', value: peerId }, + nodeEndpoint ? { countryCode: nodeEndpoint.countryCode, ip: nodeEndpoint.ip, name: 'Your IP', type: 'nodeEndpoint' } : { name: 'Your IP', value: 'unavailable' }, + { name: 'Data received', value: formatOptionalBytes(bandwidth.TotalIn) }, + { name: 'Data sent', value: formatOptionalBytes(bandwidth.TotalOut) }, + connectedPeersWithMapEntries, ]; }; const getP2PStats = async (mode: P2PRuntimeMode, account?: AccountShape, rpcState?: string, signal?: AbortSignal) => { if (mode === 'browser-libp2p') return getBrowserLibp2pStats(account, signal); - return getElectronKuboStats(rpcState, signal); + if (mode === 'full-node-rpc') return getFullNodeRpcStats(account, rpcState, signal); + return getElectronKuboStats(signal); }; const NodeEndpointValue = ({ row }: { row: NodeEndpointStatRow }) => { @@ -660,11 +987,11 @@ const ConnectedPeersValue = ({ row }: { row: ConnectedPeersStatRow }) => ( {row.name}: {formatCount(row.peerCount, 'peer')}, {formatCount(row.connectionCount, 'connection')} - +
{row.entries.length ? ( row.entries.map((entry) => { - const countryCode = getApproximateCountryCode(entry.address); + const countryCode = entry.countryCode ?? getApproximateCountryCode(entry.address); const flagPosition = getCountryFlagPosition(countryCode); return (
@@ -680,6 +1007,11 @@ const ConnectedPeersValue = ({ row }: { row: ConnectedPeersStatRow }) => ( {entry.status} )} + {entry.role && ( + + {entry.role === 'leecher' ? 'Leeching' : 'Seeding'} + + )}
{entry.peerId} diff --git a/src/components/settings-modal/p2p-stats-settings/peer-world-map.tsx b/src/components/settings-modal/p2p-stats-settings/peer-world-map.tsx index c828a02f..6c091e7c 100644 --- a/src/components/settings-modal/p2p-stats-settings/peer-world-map.tsx +++ b/src/components/settings-modal/p2p-stats-settings/peer-world-map.tsx @@ -1,16 +1,20 @@ import { WORLD_MAP_DOTS } from '../../../data/world-map-dots'; -import { getApproximateLatLon } from '../../../lib/peer-geo'; +import { getApproximateLatLon, type PeerMapLocation } from '../../../lib/peer-geo'; import styles from './p2p-stats-settings.module.css'; type MapPeer = { address: string; id: string; + location?: PeerMapLocation; peerId: string; + role?: 'leecher' | 'seeder'; }; // Square side per land dot, sized to cover most of a grid cell so the rasterized // Natural Earth land mask (src/data/world-map-dots.ts) reads as a halftone map. const DOT_SIZE = WORLD_MAP_DOTS.step * 0.6; +const PEER_MARKER_SIZE = 3; +const PEER_MARKER_OFFSET = PEER_MARKER_SIZE / 2; // Equirectangular projection shared with the peer markers below: x = lon + 180, // y = 90 - lat. Expand the land bitmap into one of small squares so the @@ -34,10 +38,10 @@ const LAND_PATH = (() => { })(); const PeerWorldMap = ({ peers }: { peers: MapPeer[] }) => { - const plotted: { id: string; peerId: string; x: number; y: number }[] = []; + const plotted: { id: string; label?: string; peerId: string; role?: 'leecher' | 'seeder'; x: number; y: number }[] = []; for (const peer of peers) { - const location = getApproximateLatLon(peer.address); - if (location) plotted.push({ id: peer.id, peerId: peer.peerId, x: location.lon + 180, y: 90 - location.lat }); + const location = peer.location ?? getApproximateLatLon(peer.address); + if (location) plotted.push({ id: peer.id, label: peer.location?.label, peerId: peer.peerId, role: peer.role, x: location.lon + 180, y: 90 - location.lat }); } if (!plotted.length) return null; @@ -47,12 +51,20 @@ const PeerWorldMap = ({ peers }: { peers: MapPeer[] }) => { {plotted.map((peer) => ( - - {peer.peerId} + + {peer.label ? `${peer.peerId} - ${peer.label}` : peer.peerId} ))} -
approximate locations
+
approximate IP locations
); }; diff --git a/src/lib/__tests__/p2p-runtime.test.ts b/src/lib/__tests__/p2p-runtime.test.ts index ec0c45c5..e4783e5b 100644 --- a/src/lib/__tests__/p2p-runtime.test.ts +++ b/src/lib/__tests__/p2p-runtime.test.ts @@ -49,11 +49,11 @@ describe('p2p-runtime', () => { expect(getP2PRuntimeMode({ pkc: { clients: { libp2pJsClients: { libp2pjs: {} } } } }, browserWindow)).toBe('browser-libp2p'); }); - it('detects electron Kubo RPC accounts only in electron runtime', () => { + it('detects full-node RPC accounts in browser and electron runtimes', () => { const account = { pkcOptions: { pkcRpcClientsOptions: ['ws://localhost:9138'] } }; expect(getP2PRuntimeMode(account, electronWindow)).toBe('electron-kubo-rpc'); - expect(getP2PRuntimeMode(account, browserWindow)).toBeNull(); + expect(getP2PRuntimeMode(account, browserWindow)).toBe('full-node-rpc'); }); it('shows p2p settings in browsers when pure p2p is enabled by default', () => { @@ -70,6 +70,13 @@ describe('p2p-runtime', () => { expect(isBrowserPureP2PEnabled(gatewayAccount, p2pBrowserWindowWithDisabledPureP2P)).toBe(false); }); + it('still shows browser full-node RPC stats when browser pure p2p was toggled off', () => { + const account = { pkcOptions: { pkcRpcClientsOptions: ['ws://node.example'] } }; + + expect(isBrowserPureP2PEnabled(account, browserWindowWithDisabledPureP2P)).toBe(false); + expect(shouldShowP2PSettingsSection(account, browserWindowWithDisabledPureP2P)).toBe(true); + }); + it('builds browser p2p and gateway account options without a direct pkc-js import', () => { const account = { pkcOptions: { diff --git a/src/lib/__tests__/peer-geo.test.ts b/src/lib/__tests__/peer-geo.test.ts index 0cf7a483..88376d1d 100644 --- a/src/lib/__tests__/peer-geo.test.ts +++ b/src/lib/__tests__/peer-geo.test.ts @@ -6,6 +6,7 @@ import { extractIpv6FromAddress, fetchOwnIpCountryCode, fetchOwnPublicEndpoint, + fetchPeerMapLocation, getApproximateCountryCode, getApproximateLatLon, getFirstPublicIpFromAddresses, @@ -98,15 +99,34 @@ describe('getFirstPublicIpFromAddresses', () => { describe('fetchOwnPublicEndpoint', () => { it('caches the fetched public endpoint', async () => { - const fetchMock = vi.fn().mockResolvedValue({ + const fetchMock = vi.fn(async (url: string | URL | Request) => ({ ok: true, - json: async () => ({ country: 'US', ip: '2001:4860:4860::8888' }), - }); + json: async () => + String(url).startsWith('https://free.freeipapi.com/api/json/') + ? { + cityName: 'Mountain View', + countryCode: 'US', + latitude: 37.422, + longitude: -122.085, + regionName: 'California', + } + : { country: 'US', ip: '2001:4860:4860::8888' }, + })); vi.stubGlobal('fetch', fetchMock); - await expect(fetchOwnPublicEndpoint()).resolves.toEqual({ countryCode: 'us', ip: '2001:4860:4860::8888' }); - await expect(fetchOwnPublicEndpoint()).resolves.toEqual({ countryCode: 'us', ip: '2001:4860:4860::8888' }); - expect(fetchMock).toHaveBeenCalledTimes(1); + await expect(fetchOwnPublicEndpoint()).resolves.toMatchObject({ + countryCode: 'us', + ip: '2001:4860:4860::8888', + location: { + countryCode: 'us', + label: 'Mountain View, California, US', + lat: 37.422, + lon: -122.085, + source: 'geoip', + }, + }); + await expect(fetchOwnPublicEndpoint()).resolves.toMatchObject({ countryCode: 'us', ip: '2001:4860:4860::8888' }); + expect(fetchMock).toHaveBeenCalledTimes(2); vi.unstubAllGlobals(); }); @@ -147,6 +167,65 @@ describe('fetchOwnIpCountryCode', () => { }); }); +describe('fetchPeerMapLocation', () => { + it('resolves and caches a city-level peer location', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + cityName: 'Haarlem', + countryCode: 'NL', + ipAddress: '91.234.199.189', + latitude: 52.3874, + longitude: 4.64622, + regionName: 'North Holland', + }), + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(fetchPeerMapLocation('/ip4/91.234.199.189/tcp/4001')).resolves.toMatchObject({ + countryCode: 'nl', + label: 'Haarlem, North Holland, NL', + lat: 52.3874, + lon: 4.64622, + source: 'geoip', + }); + await expect(fetchPeerMapLocation('/ip4/91.234.199.189/tcp/4001')).resolves.toMatchObject({ + lat: 52.3874, + lon: 4.64622, + source: 'geoip', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('https://free.freeipapi.com/api/json/91.234.199.189'); + + vi.unstubAllGlobals(); + }); + + it('falls back to the offline country estimate when lookup fails', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false }); + vi.stubGlobal('fetch', fetchMock); + + const location = await fetchPeerMapLocation(addr('80.80.80.80')); + const centroid = COUNTRY_CENTROIDS[location!.countryCode!]; + + expect(location).toMatchObject({ source: 'coarse' }); + expect(centroid).toBeDefined(); + expect(Math.abs(location!.lat - centroid.lat)).toBeLessThanOrEqual(1); + expect(Math.abs(location!.lon - centroid.lon)).toBeLessThanOrEqual(1.3); + + vi.unstubAllGlobals(); + }); + + it('does not call GeoIP for private peer addresses', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(fetchPeerMapLocation('/ip4/10.0.0.1/tcp/4001')).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); +}); + describe('getApproximateCountryCode', () => { it('returns undefined when the peer cannot be placed offline', () => { expect(getApproximateCountryCode('/ip4/10.0.0.1/tcp/4001')).toBeUndefined(); diff --git a/src/lib/p2p-runtime.ts b/src/lib/p2p-runtime.ts index 488653bd..c2483f18 100644 --- a/src/lib/p2p-runtime.ts +++ b/src/lib/p2p-runtime.ts @@ -2,7 +2,7 @@ import { getBrowserGatewayPkcOptions, getBrowserPureP2PPkcOptions, isElectronRun export const P2P_STATS_SECTION_ID = 'p2p-stats-settings'; -export type P2PRuntimeMode = 'browser-libp2p' | 'electron-kubo-rpc'; +export type P2PRuntimeMode = 'browser-libp2p' | 'electron-kubo-rpc' | 'full-node-rpc'; type AccountProtocolOptions = { httpRoutersOptions?: string[]; @@ -39,8 +39,8 @@ export const getP2PRuntimeMode = (account?: unknown, targetWindow: Window = wind return 'browser-libp2p'; } - if (isElectronRuntime(targetWindow) && (hasArrayItems(protocolOptions?.pkcRpcClientsOptions) || hasObjectItems(clients?.pkcRpcClients))) { - return 'electron-kubo-rpc'; + if (hasArrayItems(protocolOptions?.pkcRpcClientsOptions) || hasObjectItems(clients?.pkcRpcClients)) { + return isElectronRuntime(targetWindow) ? 'electron-kubo-rpc' : 'full-node-rpc'; } return null; diff --git a/src/lib/peer-geo.ts b/src/lib/peer-geo.ts index e9c698c9..2556d7bb 100644 --- a/src/lib/peer-geo.ts +++ b/src/lib/peer-geo.ts @@ -1,11 +1,8 @@ -// Offline, approximate peer geolocation for the P2P stats world map. +// Peer geolocation helpers for the P2P stats panel. // -// Connected peer geolocation intentionally avoids any external geolocation API: -// 5chan is serverless and privacy-focused, so we must not leak the set of peers -// a user is connected to. -// Instead we map an IPv4 address to its Regional Internet Registry (RIR) region at -// continent resolution, using the coarse IANA /8 allocation table below. Positions -// are therefore approximate ("roughly which continent"), not precise coordinates. +// The world map first tries a public GeoIP lookup for the peer endpoint IP so the +// marker can land near the reported city/region. If that lookup fails, the map +// falls back to the offline RIR/country-centroid estimate below. import { COUNTRY_CENTROIDS } from '../data/country-centroids'; @@ -24,10 +21,22 @@ const formatAddressString = (address: unknown): string => { export type PublicEndpoint = { countryCode?: string; ip: string; + location?: PeerMapLocation; +}; + +export type LatLon = { lat: number; lon: number }; + +export type PeerMapLocation = LatLon & { + countryCode?: string; + label?: string; + source: 'coarse' | 'geoip'; }; const COUNTRY_LOOKUP_URL = 'https://api.country.is'; const PUBLIC_IPV4_LOOKUP_URL = 'https://api64.ipify.org?format=json'; +const PEER_LOCATION_LOOKUP_URL = 'https://free.freeipapi.com/api/json'; +const PEER_LOCATION_CACHE_MS = 24 * 60 * 60_000; +const PEER_LOCATION_FAILURE_CACHE_MS = 10 * 60_000; export const extractIpFromAddress = (address: string): string | null => extractIpv4FromAddress(address) ?? extractIpv6FromAddress(address); @@ -40,6 +49,7 @@ export const getFirstPublicIpFromAddresses = (addresses: unknown[]): string | un }; let cachedOwnPublicEndpoint: { expiresAt: number; value?: PublicEndpoint } | undefined; +const peerLocationCache = new Map(); const normalizeLookupCountryCode = (value: unknown) => { if (typeof value !== 'string') return undefined; @@ -47,6 +57,17 @@ const normalizeLookupCountryCode = (value: unknown) => { return /^[a-z]{2}$/.test(code) ? code : undefined; }; +const normalizePlaceName = (value: unknown) => { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +}; + +const getFiniteCoordinate = (value: unknown, min: number, max: number) => { + const coordinate = Number(value); + return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? coordinate : undefined; +}; + const parsePublicEndpoint = (data: unknown): PublicEndpoint | undefined => { if (!data || typeof data !== 'object') return undefined; const ip = (data as { ip?: unknown }).ip; @@ -67,21 +88,49 @@ const fetchPublicEndpoint = async (url: string, signal?: AbortSignal): Promise

{ + if (!data || typeof data !== 'object') return undefined; + const lat = getFiniteCoordinate((data as { latitude?: unknown }).latitude, -85, 85); + const lon = getFiniteCoordinate((data as { longitude?: unknown }).longitude, -180, 180); + if (lat === undefined || lon === undefined) return undefined; + + const countryCode = normalizeLookupCountryCode((data as { countryCode?: unknown }).countryCode); + const city = normalizePlaceName((data as { cityName?: unknown }).cityName); + const region = normalizePlaceName((data as { regionName?: unknown }).regionName); + const label = [city, region, countryCode?.toUpperCase()].filter(Boolean).join(', ') || undefined; + return { + countryCode, + label, + lat, + lon, + source: 'geoip', + }; +}; + // Fetches the browser node's own public endpoint for the P2P stats panel when // libp2p only advertises local/private listen addresses (common in browser nodes // and VPN setups). This only asks about the current browser's public endpoint; -// connected peer geolocation remains offline/approximate below. +// connected peer geolocation uses fetchPeerMapLocation below. export const fetchOwnPublicEndpoint = async (signal?: AbortSignal): Promise => { if (cachedOwnPublicEndpoint && Date.now() < cachedOwnPublicEndpoint.expiresAt) return cachedOwnPublicEndpoint.value; const endpoint = await fetchPublicEndpoint(COUNTRY_LOOKUP_URL, signal); if (endpoint) { - cachedOwnPublicEndpoint = { expiresAt: Date.now() + 60_000, value: endpoint }; - return endpoint; + const location = await fetchIpMapLocation(endpoint.ip, signal); + const value = { + ...endpoint, + countryCode: endpoint.countryCode ?? location?.countryCode, + location, + }; + cachedOwnPublicEndpoint = { expiresAt: Date.now() + 60_000, value }; + return value; } const ipv4Endpoint = await fetchPublicEndpoint(PUBLIC_IPV4_LOOKUP_URL, signal); - const fallback = ipv4Endpoint?.ip ? { countryCode: getApproximateCountryCode(`/ip4/${ipv4Endpoint.ip}/tcp/0`), ip: ipv4Endpoint.ip } : undefined; + const ipv4Location = ipv4Endpoint?.ip ? await fetchIpMapLocation(ipv4Endpoint.ip, signal) : undefined; + const fallback = ipv4Endpoint?.ip + ? { countryCode: ipv4Location?.countryCode ?? getApproximateCountryCode(`/ip4/${ipv4Endpoint.ip}/tcp/0`), ip: ipv4Endpoint.ip, location: ipv4Location } + : undefined; // Don't cache an empty result produced by an aborted lookup (e.g. the panel was // closed mid-request): that is cancellation, not a real failure, so a later // reopen should retry instead of being served a cached blank for 30s. @@ -93,22 +142,18 @@ const ownIpCountryCache = new Map // Accurate country code for the local node's OWN public IP, so the P2P stats // "Your IP" flag matches the address shown instead of the coarse continent guess. -// Like fetchOwnPublicEndpoint, this only ever looks up the user's own node -// address; connected peer geolocation stays offline (getApproximateCountryCode) -// so the set of peers a user connects to is never sent to an external API. +// Like fetchOwnPublicEndpoint, this only asks about the user's own node address. export const fetchOwnIpCountryCode = async (ip: string, signal?: AbortSignal): Promise => { const cached = ownIpCountryCache.get(ip); if (cached && Date.now() < cached.expiresAt) return cached.value; const endpoint = await fetchPublicEndpoint(`${COUNTRY_LOOKUP_URL}/${ip}`, signal); - const value = endpoint?.countryCode; + const value = endpoint?.countryCode ?? getApproximateCountryCode(`/ip4/${ip}/tcp/0`); // See fetchOwnPublicEndpoint: skip caching when the lookup was aborted so a // cancelled request does not blank the flag for 60s on the next open. if (!signal?.aborted) ownIpCountryCache.set(ip, { expiresAt: Date.now() + 60_000, value }); return value; }; -export type LatLon = { lat: number; lon: number }; - type Region = 'AF' | 'AS' | 'EU' | 'NA' | 'SA'; const REGION_CENTROIDS: Record = { @@ -235,6 +280,11 @@ const isProbablyPublicIpv6 = (ip: string): boolean => { const isPublicIpAddress = (ip: string): boolean => (ip.includes(':') ? isProbablyPublicIpv6(ip) : !isPrivateOrReservedIpv4(ip)); +const getPublicIpFromAddress = (address: string): string | undefined => { + const ip = extractIpFromAddress(address); + return ip && isPublicIpAddress(ip) ? ip : undefined; +}; + const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); const hashOctets = (parts: number[]) => { @@ -263,6 +313,18 @@ export const getApproximateLatLon = (address: string): LatLon | null => { return { lat: clamp(centroid.lat + latOffset, -85, 85), lon: clamp(centroid.lon + lonOffset, -180, 180) }; }; +const getCoarsePeerMapLocation = (address: string): PeerMapLocation | undefined => { + const location = getApproximateLatLon(address); + if (!location) return undefined; + const countryCode = getApproximateCountryCode(address); + return { + ...location, + countryCode, + label: countryCode ? countryCode.toUpperCase() : undefined, + source: 'coarse', + }; +}; + // Representative countries per region. The RIR table only resolves to a continent, // so the flag is a deterministic, approximate pick from the region's common // countries — consistent with the map's "approximate locations" framing, not real @@ -285,3 +347,31 @@ export const getApproximateCountryCode = (address: string): string | undefined = const pool = REGION_COUNTRIES[REGION_BY_OCTET[parts[0]]]; return pool[hashOctets(parts) % pool.length]; }; + +export const fetchIpMapLocation = async (ip: string, signal?: AbortSignal): Promise => { + const cached = peerLocationCache.get(ip); + if (cached && Date.now() < cached.expiresAt) return cached.value; + + let value: PeerMapLocation | undefined; + try { + const response = await fetch(`${PEER_LOCATION_LOOKUP_URL}/${encodeURIComponent(ip)}`, { signal }); + if (response.ok) value = parsePeerLocation(await response.json()); + } catch { + value = undefined; + } + + // See fetchOwnPublicEndpoint: a cancelled request should not poison the cache. + if (!signal?.aborted) { + peerLocationCache.set(ip, { + expiresAt: Date.now() + (value ? PEER_LOCATION_CACHE_MS : PEER_LOCATION_FAILURE_CACHE_MS), + value, + }); + } + return value; +}; + +export const fetchPeerMapLocation = async (address: string, signal?: AbortSignal): Promise => { + const ip = getPublicIpFromAddress(address); + if (!ip) return undefined; + return (await fetchIpMapLocation(ip, signal)) ?? getCoarsePeerMapLocation(address); +};