mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(p2p stats): improve own-IP geolocation and world map accuracy (#1138)
* fix(p2p stats): improve own-IP geolocation and world map accuracy Resolve the user's public endpoint when libp2p only advertises private listen addresses, look up an accurate country flag for "Your IP", snap peer markers to country centroids, and add leeching seeder link plus panel layout tweaks. * fix(p2p stats): skip caching own-IP lookups cancelled by abort When the P2P stats panel unmounts mid-request, its AbortSignal cancels the in-flight fetchOwnPublicEndpoint / fetchOwnIpCountryCode calls. Those empty results were still cached for 30-60s, so reopening the panel within that window showed "Your IP" as unavailable or without a country flag even though nothing had actually failed. Skip caching when the signal aborted so a later open retries. Addresses Cursor Bugbot finding.
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractIpv4FromAddress, getApproximateCountryCode, getApproximateLatLon, isPrivateOrReservedIpv4 } from '../peer-geo';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { COUNTRY_CENTROIDS } from '../../data/country-centroids';
|
||||
import {
|
||||
extractIpFromAddress,
|
||||
extractIpv4FromAddress,
|
||||
extractIpv6FromAddress,
|
||||
fetchOwnIpCountryCode,
|
||||
fetchOwnPublicEndpoint,
|
||||
getApproximateCountryCode,
|
||||
getApproximateLatLon,
|
||||
getFirstPublicIpFromAddresses,
|
||||
isPrivateOrReservedIpv4,
|
||||
} from '../peer-geo';
|
||||
|
||||
const addr = (ip: string) => `/ip4/${ip}/tcp/4001/ws/p2p/12D3KooWExample`;
|
||||
|
||||
@@ -19,6 +30,13 @@ describe('extractIpv4FromAddress', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractIpv6FromAddress', () => {
|
||||
it('extracts the IPv6 from a multiaddr', () => {
|
||||
expect(extractIpv6FromAddress('/ip6/2001:4860:4860::8888/tcp/4001/ws')).toBe('2001:4860:4860::8888');
|
||||
expect(extractIpFromAddress('/ip6/2001:4860:4860::8888/tcp/4001/ws')).toBe('2001:4860:4860::8888');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPrivateOrReservedIpv4', () => {
|
||||
it('flags private and reserved ranges', () => {
|
||||
for (const ip of ['10.0.0.1', '172.16.5.4', '192.168.1.10', '127.0.0.1', '169.254.1.1', '100.64.0.1', '0.0.0.0', '239.255.0.1']) {
|
||||
@@ -44,27 +62,17 @@ describe('getApproximateLatLon', () => {
|
||||
expect(getApproximateLatLon(addr('8.8.8.8'))).toEqual(getApproximateLatLon(addr('8.8.8.8')));
|
||||
});
|
||||
|
||||
it('places addresses in the expected continental region', () => {
|
||||
const na = getApproximateLatLon(addr('8.8.8.8'));
|
||||
expect(na?.lon).toBeLessThan(-80); // North America
|
||||
expect(na?.lat).toBeGreaterThan(30);
|
||||
|
||||
const eu = getApproximateLatLon(addr('80.80.80.80'));
|
||||
expect(eu?.lon).toBeGreaterThan(0);
|
||||
expect(eu?.lon).toBeLessThan(30);
|
||||
expect(eu?.lat).toBeGreaterThan(40);
|
||||
|
||||
const as = getApproximateLatLon(addr('1.1.1.1'));
|
||||
expect(as?.lon).toBeGreaterThan(90);
|
||||
|
||||
const af = getApproximateLatLon(addr('41.0.0.1'));
|
||||
expect(af?.lon).toBeGreaterThan(8);
|
||||
expect(af?.lon).toBeLessThan(34);
|
||||
expect(af?.lat).toBeLessThan(12);
|
||||
|
||||
const sa = getApproximateLatLon(addr('200.0.0.1'));
|
||||
expect(sa?.lon).toBeLessThan(-45);
|
||||
expect(sa?.lat).toBeLessThan(-5);
|
||||
it('snaps a peer to the centroid of its flag country', () => {
|
||||
for (const ip of ['8.8.8.8', '80.80.80.80', '1.1.1.1', '41.0.0.1', '200.0.0.1', '194.110.247.146', '91.234.199.189']) {
|
||||
const country = getApproximateCountryCode(addr(ip));
|
||||
expect(country).toBeDefined();
|
||||
const centroid = COUNTRY_CENTROIDS[country!];
|
||||
expect(centroid).toBeDefined();
|
||||
const loc = getApproximateLatLon(addr(ip))!;
|
||||
// Marker sits at the country centroid, within the small placement jitter.
|
||||
expect(Math.abs(loc.lat - centroid.lat)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(loc.lon - centroid.lon)).toBeLessThanOrEqual(1.3);
|
||||
}
|
||||
});
|
||||
|
||||
it('stays within valid coordinate bounds', () => {
|
||||
@@ -77,6 +85,68 @@ describe('getApproximateLatLon', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
expect(getFirstPublicIpFromAddresses(['/ip4/127.0.0.1/tcp/4001', '/ip6/2001:4860:4860::8888/tcp/443'])).toBe('2001:4860:4860::8888');
|
||||
});
|
||||
|
||||
it('returns undefined when only private addresses are present', () => {
|
||||
expect(getFirstPublicIpFromAddresses(['/ip4/127.0.0.1/tcp/4001', '/ip4/10.0.0.5/tcp/4001', '/ip6/fd00::1/tcp/4001'])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchOwnPublicEndpoint', () => {
|
||||
it('caches the fetched public endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ 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);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchOwnIpCountryCode', () => {
|
||||
it("resolves and caches the accurate country for the node's own ip", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ country: 'VN', ip: '172.225.56.8' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(fetchOwnIpCountryCode('172.225.56.8')).resolves.toBe('vn');
|
||||
await expect(fetchOwnIpCountryCode('172.225.56.8')).resolves.toBe('vn');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://api.country.is/172.225.56.8');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not cache a result from an aborted lookup', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ country: 'VN', ip: '203.0.113.50' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
// An aborted request is cancellation, not a real result, so it must not be cached.
|
||||
await fetchOwnIpCountryCode('203.0.113.50', controller.signal);
|
||||
// A later non-aborted call must perform a fresh lookup instead of a cached blank.
|
||||
await expect(fetchOwnIpCountryCode('203.0.113.50')).resolves.toBe('vn');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApproximateCountryCode', () => {
|
||||
it('returns undefined when the peer cannot be placed offline', () => {
|
||||
expect(getApproximateCountryCode('/ip4/10.0.0.1/tcp/4001')).toBeUndefined();
|
||||
|
||||
+127
-7
@@ -1,11 +1,112 @@
|
||||
// Offline, approximate peer geolocation for the P2P stats world map.
|
||||
//
|
||||
// This 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.
|
||||
// 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.
|
||||
|
||||
import { COUNTRY_CENTROIDS } from '../data/country-centroids';
|
||||
|
||||
const formatAddressString = (address: unknown): string => {
|
||||
if (typeof address === 'string') return address;
|
||||
if (address && typeof address === 'object' && typeof (address as { toString?: unknown }).toString === 'function') {
|
||||
try {
|
||||
return String(address);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export type PublicEndpoint = {
|
||||
countryCode?: string;
|
||||
ip: string;
|
||||
};
|
||||
|
||||
const COUNTRY_LOOKUP_URL = 'https://api.country.is';
|
||||
const PUBLIC_IPV4_LOOKUP_URL = 'https://api64.ipify.org?format=json';
|
||||
|
||||
export const extractIpFromAddress = (address: string): string | null => extractIpv4FromAddress(address) ?? extractIpv6FromAddress(address);
|
||||
|
||||
export const getFirstPublicIpFromAddresses = (addresses: unknown[]): string | undefined => {
|
||||
for (const address of addresses) {
|
||||
const ip = extractIpFromAddress(formatAddressString(address));
|
||||
if (ip && isPublicIpAddress(ip)) return ip;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let cachedOwnPublicEndpoint: { expiresAt: number; value?: PublicEndpoint } | undefined;
|
||||
|
||||
const normalizeLookupCountryCode = (value: unknown) => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const code = value.trim().toLowerCase();
|
||||
return /^[a-z]{2}$/.test(code) ? code : undefined;
|
||||
};
|
||||
|
||||
const parsePublicEndpoint = (data: unknown): PublicEndpoint | undefined => {
|
||||
if (!data || typeof data !== 'object') return undefined;
|
||||
const ip = (data as { ip?: unknown }).ip;
|
||||
if (typeof ip !== 'string' || !isPublicIpAddress(ip)) return undefined;
|
||||
return {
|
||||
countryCode: normalizeLookupCountryCode((data as { country?: unknown }).country),
|
||||
ip,
|
||||
};
|
||||
};
|
||||
|
||||
const fetchPublicEndpoint = async (url: string, signal?: AbortSignal): Promise<PublicEndpoint | undefined> => {
|
||||
try {
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) return undefined;
|
||||
return parsePublicEndpoint(await response.json());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
export const fetchOwnPublicEndpoint = async (signal?: AbortSignal): Promise<PublicEndpoint | undefined> => {
|
||||
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 ipv4Endpoint = await fetchPublicEndpoint(PUBLIC_IPV4_LOOKUP_URL, signal);
|
||||
const fallback = ipv4Endpoint?.ip ? { countryCode: getApproximateCountryCode(`/ip4/${ipv4Endpoint.ip}/tcp/0`), ip: ipv4Endpoint.ip } : 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.
|
||||
if (!signal?.aborted) cachedOwnPublicEndpoint = { expiresAt: Date.now() + 30_000, value: fallback };
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const ownIpCountryCache = new Map<string, { expiresAt: number; value?: string }>();
|
||||
|
||||
// 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.
|
||||
export const fetchOwnIpCountryCode = async (ip: string, signal?: AbortSignal): Promise<string | undefined> => {
|
||||
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;
|
||||
// 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';
|
||||
@@ -99,6 +200,11 @@ export const extractIpv4FromAddress = (address: string): string | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const extractIpv6FromAddress = (address: string): string | null => {
|
||||
const direct = /\/ip6\/([^/]+)/.exec(address);
|
||||
return direct ? direct[1] : null;
|
||||
};
|
||||
|
||||
const parseOctets = (ip: string): number[] | null => {
|
||||
const parts = ip.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
|
||||
@@ -118,6 +224,17 @@ export const isPrivateOrReservedIpv4 = (ip: string): boolean => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const isProbablyPublicIpv6 = (ip: string): boolean => {
|
||||
const normalized = ip.trim().toLowerCase();
|
||||
if (!normalized.includes(':')) return false;
|
||||
if (normalized === '::' || normalized === '::1') return false;
|
||||
if (normalized.startsWith('fe80:') || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('ff')) return false;
|
||||
if (normalized.startsWith('2001:db8:')) return false;
|
||||
return /^[0-9a-f:.]+$/.test(normalized);
|
||||
};
|
||||
|
||||
const isPublicIpAddress = (ip: string): boolean => (ip.includes(':') ? isProbablyPublicIpv6(ip) : !isPrivateOrReservedIpv4(ip));
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const hashOctets = (parts: number[]) => {
|
||||
@@ -134,12 +251,15 @@ export const getApproximateLatLon = (address: string): LatLon | null => {
|
||||
const parts = parseOctets(ip);
|
||||
if (!parts) return null;
|
||||
|
||||
const centroid = REGION_CENTROIDS[REGION_BY_OCTET[parts[0]]];
|
||||
const hash = hashOctets(parts);
|
||||
// Deterministic intra-region spread so peers don't stack on one point. The
|
||||
// region is the meaningful signal; the offset is cosmetic.
|
||||
const lonOffset = ((hash % 1000) / 1000 - 0.5) * 24; // +/- 12 deg
|
||||
const latOffset = ((Math.floor(hash / 1000) % 1000) / 1000 - 0.5) * 14; // +/- 7 deg
|
||||
// Snap the marker to the centroid of the same country shown as the peer's flag
|
||||
// (getApproximateCountryCode), falling back to the continent centroid if that
|
||||
// country has no known centroid. A small deterministic jitter keeps multiple
|
||||
// peers in one country from stacking exactly while staying near its center.
|
||||
const country = getApproximateCountryCode(address);
|
||||
const centroid = (country && COUNTRY_CENTROIDS[country]) || REGION_CENTROIDS[REGION_BY_OCTET[parts[0]]];
|
||||
const lonOffset = ((hash % 1000) / 1000 - 0.5) * 2.4; // +/- 1.2 deg
|
||||
const latOffset = ((Math.floor(hash / 1000) % 1000) / 1000 - 0.5) * 1.6; // +/- 0.8 deg
|
||||
return { lat: clamp(centroid.lat + latOffset, -85, 85), lon: clamp(centroid.lon + lonOffset, -180, 180) };
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user