mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+107
-17
@@ -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<string, { expiresAt: number; value?: PeerMapLocation }>();
|
||||
|
||||
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<P
|
||||
}
|
||||
};
|
||||
|
||||
const parsePeerLocation = (data: unknown): PeerMapLocation | undefined => {
|
||||
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<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 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<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.
|
||||
// Like fetchOwnPublicEndpoint, this only asks about the user's own node address.
|
||||
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;
|
||||
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<Region, LatLon> = {
|
||||
@@ -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<PeerMapLocation | undefined> => {
|
||||
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<PeerMapLocation | undefined> => {
|
||||
const ip = getPublicIpFromAddress(address);
|
||||
if (!ip) return undefined;
|
||||
return (await fetchIpMapLocation(ip, signal)) ?? getCoarsePeerMapLocation(address);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user