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 9d4c205a..4c748ad8 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 @@ -62,7 +62,7 @@ describe('P2PStatsSettings', () => { }; testState.rpcSettings = { state: 'disconnected' }; testState.setAccountMock.mockReset().mockResolvedValue(undefined); - // Default: own-IP country lookups (api.country.is) resolve offline so browser + // Default: own-IP endpoint lookups resolve offline so browser // stats tests never hit the network. Individual tests can override this stub. vi.stubGlobal( 'fetch', @@ -151,8 +151,9 @@ describe('P2PStatsSettings', () => { expect(seederLink).not.toBeNull(); expect(seederLink?.textContent).toBe('want to seed?'); expect(rows.get('Your IP')).toContain('147.75.84.175'); - // The own IP is geolocated accurately (per-IP lookup), not via the coarse peer guess. - expect(fetch).toHaveBeenCalledWith('https://api.country.is/147.75.84.175', expect.objectContaining({ signal: expect.any(AbortSignal) })); + // Browser mode resolves "Your IP" from the browser's public endpoint, not + // from libp2p observed/WebRTC addresses. + expect(fetch).toHaveBeenCalledWith('https://api.ipify.org?format=json', expect.objectContaining({ signal: expect.any(AbortSignal) })); const yourIpRow = Array.from(container.querySelectorAll('tr')).find((row) => row.textContent?.includes('Your IP')); expect(yourIpRow?.querySelector('[role="img"]')).not.toBeNull(); expect(container.textContent).not.toContain('browser Helia'); @@ -254,6 +255,12 @@ describe('P2PStatsSettings', () => { 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.ipify.org?format=json') { + return { + ok: true, + json: async () => ({ ip: '117.2.120.113' }), + }; + } if (requestUrl === 'https://api.country.is/117.2.120.113') { return { ok: false, @@ -320,6 +327,98 @@ describe('P2PStatsSettings', () => { expect(Number(marker?.getAttribute('y'))).toBeCloseTo(72.43, 1); }); + it('keeps browser "Your IP" on the browser public endpoint when libp2p exposes a different public address', async () => { + const fetchMock = vi.fn(async (url: string | URL | Request) => { + const requestUrl = String(url); + if (requestUrl === 'https://api.ipify.org?format=json') { + return { + ok: true, + json: async () => ({ ip: '104.28.68.171' }), + }; + } + if (requestUrl === 'https://api.country.is/104.28.68.171') { + return { + ok: true, + json: async () => ({ country: 'VN', ip: '104.28.68.171' }), + }; + } + if (requestUrl === 'https://free.freeipapi.com/api/json/104.28.68.171') { + return { + ok: true, + json: async () => ({ + cityName: 'Toronto', + countryCode: 'CA', + ipAddress: '104.28.68.171', + latitude: 43.6532, + longitude: -79.3832, + regionName: 'Ontario', + }), + }; + } + if (requestUrl === 'https://free.freeipapi.com/api/json/146.75.187.55') { + return { + ok: true, + json: async () => ({ + cityName: 'Bandar Seri Begawan', + countryCode: 'BN', + ipAddress: '146.75.187.55', + latitude: 4.89234, + longitude: 114.942, + regionName: 'Brunei-Muara', + }), + }; + } + 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: () => [ + { + localAddr: { toString: () => '/ip4/146.75.187.55/udp/4001/webrtc-direct' }, + }, + ], + getMultiaddrs: () => ['/ip4/146.75.187.55/udp/4001/webrtc-direct'], + 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 - VN'); + expect(rows.get('Your IP')).toContain('104.28.68.171'); + expect(rows.get('Your IP')).not.toContain('146.75.187.55'); + expect(yourIpRow?.querySelector('[role="img"]')?.getAttribute('aria-label')).toBe('Vietnam'); + expect(marker).not.toBeNull(); + expect(container.querySelector('svg rect title')?.textContent).not.toContain('Toronto'); + expect(fetchMock).not.toHaveBeenCalledWith('https://free.freeipapi.com/api/json/146.75.187.55', expect.anything()); + }); + it('reads browser transfer counters from Helia bitswap ledgers', async () => { testState.account = { ...testState.account, @@ -401,13 +500,20 @@ describe('P2PStatsSettings', () => { }); it('falls back to the browser node public endpoint when Helia exposes no public address', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ + const fetchMock = vi.fn(async (url: string | URL | Request) => { + const requestUrl = String(url); + if (requestUrl === 'https://api.ipify.org?format=json') { + return { + ok: true, + json: async () => ({ ip: '104.28.68.171' }), + }; + } + return { ok: true, - json: async () => ({ country: 'US', ip: '2001:4860:4860::8888' }), - }), - ); + json: async () => ({ country: 'VN', ip: '104.28.68.171' }), + }; + }); + vi.stubGlobal('fetch', fetchMock); testState.account = { ...testState.account, pkcOptions: { @@ -442,11 +548,11 @@ describe('P2PStatsSettings', () => { await act(async () => Promise.resolve()); const rows = getStatRows(); - expect(rows.get('Your IP')).toContain('2001:4860:4860::8888'); + expect(rows.get('Your IP')).toContain('104.28.68.171'); expect(rows.get('Your IP')).not.toContain('unknown'); const yourIpRow = Array.from(container.querySelectorAll('tr')).find((row) => row.textContent?.includes('Your IP')); expect(yourIpRow?.querySelector('[role="img"]')).not.toBeNull(); - expect(fetch).toHaveBeenCalledWith('https://api.country.is', expect.objectContaining({ signal: expect.any(AbortSignal) })); + expect(fetch).toHaveBeenCalledWith('https://api.ipify.org?format=json', expect.objectContaining({ signal: expect.any(AbortSignal) })); }); it('reports seeding only when browser Helia can add and publish provider records', async () => { 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 737e0b0c..c4c168c9 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 @@ -8,6 +8,7 @@ import { fetchOwnPublicEndpoint, fetchPeerMapLocation, getApproximateCountryCode, + getCountryConsistentLocation, getFirstPublicIpFromAddresses, isPrivateOrReservedIpv4, type PeerMapLocation, @@ -90,11 +91,7 @@ type StatsAction = type Libp2pClientShape = { _helia?: { libp2p?: { - components?: { - addressManager?: Libp2pAddressManagerShape; - }; getConnections?: () => unknown[] | Promise; - getMultiaddrs?: () => unknown[] | Promise; getPeers?: () => unknown[] | Promise; peerId?: { toString: () => string }; services?: { @@ -115,13 +112,6 @@ type Libp2pClientShape = { key?: string; }; -type Libp2pAddressManagerShape = { - getAddressesWithMetadata?: () => unknown[] | Promise; - getObservedAddrs?: () => unknown[] | Promise; -}; - -type BrowserLibp2pShape = NonNullable['libp2p']>>; - type PkcRpcClientShape = { getPeers?: () => unknown | Promise; getStats?: () => unknown | Promise; @@ -264,21 +254,6 @@ const getSafeArray = async (getValue?: () => unknown[] | Promise | un } }; -const getAddressManagerAddresses = async (libp2p?: BrowserLibp2pShape): Promise => { - const addressManager = isRecord(libp2p?.components) ? (libp2p.components.addressManager as Libp2pAddressManagerShape | undefined) : undefined; - const [observedAddrs, addressesWithMetadata] = await Promise.all([ - getSafeArray(() => addressManager?.getObservedAddrs?.()), - getSafeArray(() => addressManager?.getAddressesWithMetadata?.()), - ]); - return [ - ...observedAddrs, - ...addressesWithMetadata.flatMap((entry) => { - const address = isRecord(entry) ? (entry.multiaddr ?? entry.address) : entry; - return address ? [address] : []; - }), - ]; -}; - const getFirstPkcRpcClient = (account?: AccountShape) => getFirstObjectValue(account?.pkc?.clients?.pkcRpcClients) as PkcRpcClientShape | undefined; const getPkcRpcUrls = (account?: AccountShape) => { @@ -727,17 +702,18 @@ const resolveConnectedPeerLocations = async (row: ConnectedPeersStatRow, signal? 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. +// Resolves node-owned listen addresses for full-node and Kubo fallback paths. +// Browser libp2p observed addresses can be WebRTC relay/CDN endpoints, so browser +// mode uses fetchOwnPublicEndpoint instead. 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)]); + const resolvedCountryCode = countryCode ?? location?.countryCode ?? getApproximateCountryCode(getEndpointAddress(ip)); return { - countryCode: location?.countryCode ?? countryCode ?? getApproximateCountryCode(getEndpointAddress(ip)), + countryCode: resolvedCountryCode, ip, - location, + location: getCountryConsistentLocation(resolvedCountryCode, location), }; } return fetchOwnPublicEndpoint(signal); @@ -784,21 +760,12 @@ const getAddressListFromRecord = (record: unknown) => const getBrowserLibp2pStats = async (account?: AccountShape, signal?: AbortSignal): Promise => { const client = getFirstObjectValue(account?.pkc?.clients?.libp2pJsClients) as Libp2pClientShape | undefined; const libp2p = client?._helia?.libp2p; - const [peers, connections, multiaddrs, addressManagerAddresses] = await Promise.all([ - getSafeArray(() => libp2p?.getPeers?.()), - getSafeArray(() => libp2p?.getConnections?.()), - getSafeArray(() => libp2p?.getMultiaddrs?.()), - getAddressManagerAddresses(libp2p), - ]); - const localAddresses = connections.flatMap((connection) => { - const localAddr = isRecord(connection) ? connection.localAddr : undefined; - return localAddr ? [localAddr] : []; - }); + const [peers, connections] = await Promise.all([getSafeArray(() => libp2p?.getPeers?.()), getSafeArray(() => libp2p?.getConnections?.())]); const connectedPeersRow = getBrowserConnectedPeersRow(peers, connections); const mode = getBrowserMode(client); const [transferStats, nodeEndpoint, connectedPeers] = await Promise.all([ getBrowserTransferStats(client, connections), - resolveOwnEndpoint([...multiaddrs, ...addressManagerAddresses, ...localAddresses], signal), + fetchOwnPublicEndpoint(signal), resolveConnectedPeerLocations(connectedPeersRow, signal), ]); const connectedPeersWithMapEntries = { diff --git a/src/lib/__tests__/peer-geo.test.ts b/src/lib/__tests__/peer-geo.test.ts index 88376d1d..599d7c64 100644 --- a/src/lib/__tests__/peer-geo.test.ts +++ b/src/lib/__tests__/peer-geo.test.ts @@ -9,6 +9,7 @@ import { fetchPeerMapLocation, getApproximateCountryCode, getApproximateLatLon, + getCountryConsistentLocation, getFirstPublicIpFromAddresses, isPrivateOrReservedIpv4, } from '../peer-geo'; @@ -86,6 +87,25 @@ describe('getApproximateLatLon', () => { }); }); +describe('getCountryConsistentLocation', () => { + it('falls back to the selected country centroid when GeoIP databases disagree', () => { + expect( + getCountryConsistentLocation('VN', { + countryCode: 'ca', + label: 'Toronto, Ontario, CA', + lat: 43.6532, + lon: -79.3832, + source: 'geoip', + }), + ).toEqual({ + ...COUNTRY_CENTROIDS.vn, + countryCode: 'vn', + label: 'VN', + source: 'coarse', + }); + }); +}); + describe('getFirstPublicIpFromAddresses', () => { it('returns the first public IP from multiaddrs', () => { expect(getFirstPublicIpFromAddresses(['/ip4/127.0.0.1/tcp/4001', '/ip4/147.75.84.175/tcp/4001/ws'])).toBe('147.75.84.175'); @@ -98,35 +118,52 @@ describe('getFirstPublicIpFromAddresses', () => { }); describe('fetchOwnPublicEndpoint', () => { - it('caches the fetched public endpoint', async () => { - const fetchMock = vi.fn(async (url: string | URL | Request) => ({ - ok: true, - 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' }, - })); + it('prefers and caches the fetched IPv4 public endpoint', async () => { + const fetchMock = vi.fn(async (url: string | URL | Request) => { + const requestUrl = String(url); + if (requestUrl === 'https://api.ipify.org?format=json') { + return { + ok: true, + json: async () => ({ ip: '104.28.68.171' }), + }; + } + if (requestUrl === 'https://api.country.is/104.28.68.171') { + return { + ok: true, + json: async () => ({ country: 'VN', ip: '104.28.68.171' }), + }; + } + if (requestUrl === 'https://free.freeipapi.com/api/json/104.28.68.171') { + return { + ok: true, + json: async () => ({ + cityName: 'Toronto', + countryCode: 'CA', + latitude: 43.6532, + longitude: -79.3832, + regionName: 'Ontario', + }), + }; + } + return { + ok: true, + json: async () => ({ country: 'US', ip: '2001:4860:4860::8888' }), + }; + }); vi.stubGlobal('fetch', fetchMock); await expect(fetchOwnPublicEndpoint()).resolves.toMatchObject({ - countryCode: 'us', - ip: '2001:4860:4860::8888', + countryCode: 'vn', + ip: '104.28.68.171', location: { - countryCode: 'us', - label: 'Mountain View, California, US', - lat: 37.422, - lon: -122.085, - source: 'geoip', + countryCode: 'vn', + label: 'VN', + source: 'coarse', }, }); - await expect(fetchOwnPublicEndpoint()).resolves.toMatchObject({ countryCode: 'us', ip: '2001:4860:4860::8888' }); - expect(fetchMock).toHaveBeenCalledTimes(2); + await expect(fetchOwnPublicEndpoint()).resolves.toMatchObject({ countryCode: 'vn', ip: '104.28.68.171' }); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).not.toHaveBeenCalledWith('https://api.country.is', expect.anything()); vi.unstubAllGlobals(); }); diff --git a/src/lib/peer-geo.ts b/src/lib/peer-geo.ts index 2556d7bb..e5f1c70d 100644 --- a/src/lib/peer-geo.ts +++ b/src/lib/peer-geo.ts @@ -33,7 +33,7 @@ export type PeerMapLocation = LatLon & { }; const COUNTRY_LOOKUP_URL = 'https://api.country.is'; -const PUBLIC_IPV4_LOOKUP_URL = 'https://api64.ipify.org?format=json'; +const PUBLIC_IPV4_LOOKUP_URL = 'https://api.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; @@ -57,6 +57,32 @@ const normalizeLookupCountryCode = (value: unknown) => { return /^[a-z]{2}$/.test(code) ? code : undefined; }; +const getCountryCentroidLocation = (countryCode: string | undefined): PeerMapLocation | undefined => { + const normalizedCountryCode = normalizeLookupCountryCode(countryCode); + const centroid = normalizedCountryCode ? COUNTRY_CENTROIDS[normalizedCountryCode] : undefined; + if (!normalizedCountryCode || !centroid) return undefined; + return { + ...centroid, + countryCode: normalizedCountryCode, + label: normalizedCountryCode.toUpperCase(), + source: 'coarse', + }; +}; + +export const getCountryConsistentLocation = (countryCode: string | undefined, location: PeerMapLocation | undefined): PeerMapLocation | undefined => { + const normalizedCountryCode = normalizeLookupCountryCode(countryCode); + if (!normalizedCountryCode) return location; + if (!location) return getCountryCentroidLocation(normalizedCountryCode); + const locationCountryCode = normalizeLookupCountryCode(location.countryCode); + if (!locationCountryCode || locationCountryCode === normalizedCountryCode) { + return { + ...location, + countryCode: locationCountryCode ?? normalizedCountryCode, + }; + } + return getCountryCentroidLocation(normalizedCountryCode); +}; + const normalizePlaceName = (value: unknown) => { if (typeof value !== 'string') return undefined; const trimmed = value.trim(); @@ -88,6 +114,8 @@ const fetchPublicEndpoint = async (url: string, signal?: AbortSignal): Promise

`/ip${ip.includes(':') ? '6' : '4'}/${ip}/tcp/0`; + const parsePeerLocation = (data: unknown): PeerMapLocation | undefined => { if (!data || typeof data !== 'object') return undefined; const lat = getFiniteCoordinate((data as { latitude?: unknown }).latitude, -85, 85); @@ -107,6 +135,19 @@ const parsePeerLocation = (data: unknown): PeerMapLocation | undefined => { }; }; +const resolveOwnPublicEndpointLocation = async (endpoint: PublicEndpoint, signal?: AbortSignal): Promise => { + const [countryCode, location] = await Promise.all([ + endpoint.countryCode ? Promise.resolve(endpoint.countryCode) : fetchOwnIpCountryCode(endpoint.ip, signal), + fetchIpMapLocation(endpoint.ip, signal), + ]); + const resolvedCountryCode = endpoint.countryCode ?? countryCode ?? location?.countryCode ?? getApproximateCountryCode(getIpLookupAddress(endpoint.ip)); + return { + ...endpoint, + countryCode: resolvedCountryCode, + location: getCountryConsistentLocation(resolvedCountryCode, location), + }; +}; + // 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; @@ -114,28 +155,24 @@ const parsePeerLocation = (data: unknown): PeerMapLocation | undefined => { 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) { - const location = await fetchIpMapLocation(endpoint.ip, signal); - const value = { - ...endpoint, - countryCode: endpoint.countryCode ?? location?.countryCode, - location, - }; + const ipv4Endpoint = await fetchPublicEndpoint(PUBLIC_IPV4_LOOKUP_URL, signal); + if (ipv4Endpoint) { + const value = await resolveOwnPublicEndpointLocation(ipv4Endpoint, signal); cachedOwnPublicEndpoint = { expiresAt: Date.now() + 60_000, value }; return value; } - const ipv4Endpoint = await fetchPublicEndpoint(PUBLIC_IPV4_LOOKUP_URL, signal); - 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; + const endpoint = await fetchPublicEndpoint(COUNTRY_LOOKUP_URL, signal); + if (endpoint) { + const value = await resolveOwnPublicEndpointLocation(endpoint, signal); + cachedOwnPublicEndpoint = { expiresAt: Date.now() + 60_000, value }; + return value; + } // 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. - if (!signal?.aborted) cachedOwnPublicEndpoint = { expiresAt: Date.now() + 30_000, value: fallback }; - return fallback; + if (!signal?.aborted) cachedOwnPublicEndpoint = { expiresAt: Date.now() + 30_000, value: undefined }; + return undefined; }; const ownIpCountryCache = new Map(); @@ -147,7 +184,7 @@ export const fetchOwnIpCountryCode = async (ip: string, signal?: AbortSignal): P 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 ?? getApproximateCountryCode(`/ip4/${ip}/tcp/0`); + const value = endpoint?.countryCode; // 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 });