refactor(geoip): reconcile geoip system (#31)

This commit is contained in:
hampus-fluxer
2026-01-05 23:19:05 +01:00
committed by GitHub
parent 5d047b2856
commit 2e007b5076
86 changed files with 982 additions and 2648 deletions

View File

@@ -1,136 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {__clearIpCache, DEFAULT_CC, FETCH_TIMEOUT_MS, formatGeoipLocation, getCountryCode} from './IpUtils';
vi.mock('~/Config', () => ({Config: {geoip: {provider: 'ipinfo', host: 'geoip:8080'}}}));
describe('getCountryCode(ip)', () => {
const realFetch = globalThis.fetch;
beforeEach(() => {
__clearIpCache();
vi.useRealTimers();
vi.restoreAllMocks();
});
afterEach(() => {
globalThis.fetch = realFetch;
});
it('returns US if GEOIP_HOST is not set', async () => {
vi.doMock('~/Config', () => ({Config: {geoip: {provider: 'ipinfo', host: ''}}}));
const {getCountryCode: modFn, __clearIpCache: reset} = await import('./IpUtils');
reset();
const cc = await modFn('8.8.8.8');
expect(cc).toBe(DEFAULT_CC);
});
it('returns US for invalid IP', async () => {
const cc = await getCountryCode('not_an_ip');
expect(cc).toBe(DEFAULT_CC);
});
it('accepts bracketed IPv6', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('se')});
const cc = await getCountryCode('[2001:db8::1]');
expect(cc).toBe('SE');
});
it('returns uppercase alpha-2 on success', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('se')});
const cc = await getCountryCode('8.8.8.8');
expect(cc).toBe('SE');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
it('falls back on non-2xx', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ok: false, text: () => Promise.resolve('se')});
const cc = await getCountryCode('8.8.8.8');
expect(cc).toBe(DEFAULT_CC);
});
it('falls back on invalid body', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('USA')});
const cc = await getCountryCode('8.8.8.8');
expect(cc).toBe(DEFAULT_CC);
});
it('falls back on network error', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('fail'));
const cc = await getCountryCode('8.8.8.8');
expect(cc).toBe(DEFAULT_CC);
});
it('uses cache for repeated lookups within TTL', async () => {
const spyNow = vi.spyOn(Date, 'now');
spyNow.mockReturnValueOnce(1_000);
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('gb')});
const r1 = await getCountryCode('1.1.1.1');
expect(r1).toBe('GB');
spyNow.mockReturnValueOnce(2_000);
const r2 = await getCountryCode('1.1.1.1');
expect(r2).toBe('GB');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
it('aborts after timeout', async () => {
vi.useFakeTimers();
const abortingFetch = vi.fn<typeof fetch>(
(_url, opts) =>
new Promise((_res, rej) => {
opts?.signal?.addEventListener('abort', () =>
rej(Object.assign(new Error('AbortError'), {name: 'AbortError'})),
);
}),
);
globalThis.fetch = abortingFetch;
const p = getCountryCode('8.8.8.8');
vi.advanceTimersByTime(FETCH_TIMEOUT_MS + 5);
await expect(p).resolves.toBe(DEFAULT_CC);
expect(abortingFetch).toHaveBeenCalledTimes(1);
});
});
describe('formatGeoipLocation(result)', () => {
it('returns Unknown Location when no parts are available', () => {
const label = formatGeoipLocation({
countryCode: DEFAULT_CC,
normalizedIp: '1.1.1.1',
reason: null,
city: null,
region: null,
countryName: null,
});
expect(label).toBe('Unknown Location');
});
it('concatenates available city, region, and country', () => {
const label = formatGeoipLocation({
countryCode: 'US',
normalizedIp: '1.1.1.1',
reason: null,
city: 'San Francisco',
region: 'CA',
countryName: 'United States',
});
expect(label).toBe('San Francisco, CA, United States');
});
});

View File

@@ -25,42 +25,43 @@ import type {ICacheService} from '~/infrastructure/ICacheService';
import {Logger} from '~/Logger';
const CACHE_TTL_MS = 10 * 60 * 1000;
export const DEFAULT_CC = 'US';
export const FETCH_TIMEOUT_MS = 1500;
export const UNKNOWN_LOCATION = 'Unknown Location';
const REVERSE_DNS_CACHE_TTL_SECONDS = 24 * 60 * 60;
const REVERSE_DNS_CACHE_PREFIX = 'reverse-dns:';
export const UNKNOWN_LOCATION = 'Unknown Location';
export interface GeoipResult {
countryCode: string;
countryCode: string | null;
normalizedIp: string | null;
reason: string | null;
city: string | null;
region: string | null;
countryName: string | null;
}
interface CacheVal {
type CacheEntry = {
result: GeoipResult;
exp: number;
}
expiresAt: number;
};
const cache = new Map<string, CacheVal>();
const geoipCache = new Map<string, CacheEntry>();
let maxmindReader: Reader<CityResponse> | null = null;
let maxmindReaderPromise: Promise<Reader<CityResponse>> | null = null;
export function __clearIpCache(): void {
cache.clear();
geoipCache.clear();
}
export function __resetMaxmindReader(): void {
maxmindReader = null;
maxmindReaderPromise = null;
}
export function extractClientIp(req: Request): string | null {
const xff = (req.headers.get('X-Forwarded-For') ?? '').trim();
if (!xff) {
return null;
}
const first = xff.split(',')[0]?.trim() ?? '';
if (!xff) return null;
const [first] = xff.split(',');
if (!first) return null;
return normalizeIpString(first);
}
@@ -72,24 +73,33 @@ export function requireClientIp(req: Request): string {
return ip;
}
function buildFallbackResult(clean: string, reason: string | null): GeoipResult {
export async function lookupGeoip(req: Request): Promise<GeoipResult>;
export async function lookupGeoip(ip: string): Promise<GeoipResult>;
export async function lookupGeoip(input: string | Request): Promise<GeoipResult> {
const ip = typeof input === 'string' ? input : extractClientIp(input);
if (!ip) {
return buildFallbackResult('');
}
return lookupGeoipFromString(ip);
}
function buildFallbackResult(clean: string): GeoipResult {
return {
countryCode: DEFAULT_CC,
normalizedIp: clean,
reason,
countryCode: null,
normalizedIp: clean || null,
city: null,
region: null,
countryName: null,
};
}
async function getMaxmindReader(): Promise<Reader<CityResponse>> {
async function ensureMaxmindReader(): Promise<Reader<CityResponse>> {
if (maxmindReader) return maxmindReader;
if (!maxmindReaderPromise) {
const dbPath = Config.geoip.maxmindDbPath;
if (!dbPath) {
return Promise.reject(new Error('Missing MaxMind DB path'));
throw new Error('Missing MaxMind DB path');
}
maxmindReaderPromise = maxmind
@@ -107,127 +117,79 @@ async function getMaxmindReader(): Promise<Reader<CityResponse>> {
return maxmindReaderPromise;
}
function getSubdivisionLabel(record?: CityResponse): string | null {
function stateLabel(record?: CityResponse): string | null {
const subdivision = record?.subdivisions?.[0];
if (!subdivision) return null;
return subdivision.iso_code || subdivision.names?.en || null;
return subdivision.names?.en || subdivision.iso_code || null;
}
async function lookupMaxmind(clean: string): Promise<GeoipResult> {
const dbPath = Config.geoip.maxmindDbPath;
if (!dbPath) {
return buildFallbackResult(clean, 'maxmind_db_missing');
return buildFallbackResult(clean);
}
try {
const reader = await getMaxmindReader();
const reader = await ensureMaxmindReader();
const record = reader.get(clean);
if (!record) {
return buildFallbackResult(clean, 'maxmind_not_found');
}
if (!record) return buildFallbackResult(clean);
const isoCode = record.country?.iso_code;
const countryCode = isoCode ? isoCode.toUpperCase() : null;
const countryCode = (record.country?.iso_code ?? DEFAULT_CC).toUpperCase();
return {
countryCode,
normalizedIp: clean,
reason: null,
city: record.city?.names?.en ?? null,
region: getSubdivisionLabel(record),
countryName: record.country?.names?.en ?? countryDisplayName(countryCode) ?? null,
region: stateLabel(record),
countryName: record.country?.names?.en ?? (countryCode ? countryDisplayName(countryCode) : null) ?? null,
};
} catch (error) {
const message = (error as Error)?.message ?? 'unknown';
Logger.warn({error, maxmind_db_path: dbPath}, 'MaxMind lookup failed');
return buildFallbackResult(clean, `maxmind_error:${message}`);
const message = (error as Error).message ?? 'unknown';
Logger.warn({error, maxmind_db_path: dbPath, message}, 'MaxMind lookup failed');
return buildFallbackResult(clean);
}
}
async function lookupIpinfo(clean: string): Promise<GeoipResult> {
const host = Config.geoip.host;
if (!host) {
return buildFallbackResult(clean, 'geoip_host_missing');
}
const url = `http://${host}/lookup?ip=${encodeURIComponent(clean)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await globalThis.fetch!(url, {
signal: controller.signal,
headers: {Accept: 'text/plain'},
});
if (!res.ok) {
return buildFallbackResult(clean, `non_ok:${res.status}`);
}
const text = (await res.text()).trim().toUpperCase();
const countryCode = isAsciiUpperAlpha2(text) ? text : DEFAULT_CC;
const reason = isAsciiUpperAlpha2(text) ? null : 'invalid_response';
return {
countryCode,
normalizedIp: clean,
reason,
city: null,
region: null,
countryName: countryDisplayName(countryCode),
};
} catch (error) {
const message = (error as Error)?.message ?? 'unknown';
return buildFallbackResult(clean, `error:${message}`);
} finally {
clearTimeout(timer);
}
}
export async function getCountryCodeDetailed(ip: string): Promise<GeoipResult> {
const clean = normalizeIpString(ip);
if (!isIPv4(clean) && !isIPv6(clean)) {
return buildFallbackResult(clean, 'invalid_ip');
}
const cached = cache.get(clean);
async function resolveGeoip(clean: string): Promise<GeoipResult> {
const now = Date.now();
if (cached && now < cached.exp) {
const cached = geoipCache.get(clean);
if (cached && now < cached.expiresAt) {
return cached.result;
}
const provider = Config.geoip.provider;
const result = provider === 'maxmind' ? await lookupMaxmind(clean) : await lookupIpinfo(clean);
cache.set(clean, {result, exp: now + CACHE_TTL_MS});
const result = await lookupMaxmind(clean);
geoipCache.set(clean, {result, expiresAt: now + CACHE_TTL_MS});
return result;
}
export async function getCountryCode(ip: string): Promise<string> {
const result = await getCountryCodeDetailed(ip);
return result.countryCode;
}
async function lookupGeoipFromString(value: string): Promise<GeoipResult> {
const clean = normalizeIpString(value);
if (!isIPv4(clean) && !isIPv6(clean)) {
return buildFallbackResult(clean);
}
export async function getCountryCodeFromReq(req: Request): Promise<string> {
const ip = extractClientIp(req);
if (!ip) return DEFAULT_CC;
return await getCountryCode(ip);
return resolveGeoip(clean);
}
function countryDisplayName(code: string, locale = 'en'): string | null {
const c = code.toUpperCase();
if (!isAsciiUpperAlpha2(c)) return null;
const upper = code.toUpperCase();
if (!isAsciiUpperAlpha2(upper)) return null;
const dn = new Intl.DisplayNames([locale], {type: 'region', fallback: 'none'});
return dn.of(c) ?? null;
return dn.of(upper) ?? null;
}
export function formatGeoipLocation(result: GeoipResult): string {
export function formatGeoipLocation(result: GeoipResult): string | null {
const parts: Array<string> = [];
if (result.city) parts.push(result.city);
if (result.region) parts.push(result.region);
const countryLabel = result.countryName ?? result.countryCode;
if (countryLabel) parts.push(countryLabel);
return parts.length > 0 ? parts.join(', ') : UNKNOWN_LOCATION;
return parts.length > 0 ? parts.join(', ') : null;
}
function stripBrackets(s: string): string {
return s.startsWith('[') && s.endsWith(']') ? s.slice(1, -1) : s;
function stripBrackets(value: string): string {
return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value;
}
export function normalizeIpString(value: string): string {
@@ -239,12 +201,9 @@ export function normalizeIpString(value: string): string {
export async function getIpAddressReverse(ip: string, cacheService?: ICacheService): Promise<string | null> {
const cacheKey = `${REVERSE_DNS_CACHE_PREFIX}${ip}`;
if (cacheService) {
const cached = await cacheService.get<string | null>(cacheKey);
if (cached !== null) {
return cached === '' ? null : cached;
}
if (cached !== null) return cached === '' ? null : cached;
}
let result: string | null = null;
@@ -262,9 +221,17 @@ export async function getIpAddressReverse(ip: string, cacheService?: ICacheServi
return result;
}
function isAsciiUpperAlpha2(s: string): boolean {
if (s.length !== 2) return false;
const a = s.charCodeAt(0);
const b = s.charCodeAt(1);
return a >= 65 && a <= 90 && b >= 65 && b <= 90;
export async function getLocationLabelFromIp(ip: string): Promise<string | null> {
const result = await lookupGeoip(ip);
return formatGeoipLocation(result);
}
function isAsciiUpperAlpha2(value: string): boolean {
return (
value.length === 2 &&
value.charCodeAt(0) >= 65 &&
value.charCodeAt(0) <= 90 &&
value.charCodeAt(1) >= 65 &&
value.charCodeAt(1) <= 90
);
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import Bowser from 'bowser';
import {Logger} from '~/Logger';
export interface UserAgentInfo {
clientOs: string;
detectedPlatform: string;
}
const UNKNOWN_LABEL = 'Unknown';
function formatName(name?: string | null): string {
const normalized = name?.trim();
return normalized || UNKNOWN_LABEL;
}
export function parseUserAgentSafe(userAgentRaw: string): UserAgentInfo {
const ua = userAgentRaw.trim();
if (!ua) return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
try {
const parser = Bowser.getParser(ua);
return {
clientOs: formatName(parser.getOSName()),
detectedPlatform: formatName(parser.getBrowserName()),
};
} catch (error) {
Logger.warn({error}, 'Failed to parse user agent');
return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
}
}
export function resolveSessionClientInfo(args: {userAgent: string | null; isDesktopClient: boolean | null}): {
clientOs: string;
clientPlatform: string;
} {
const parsed = parseUserAgentSafe(args.userAgent ?? '');
const clientPlatform = args.isDesktopClient ? 'Fluxer Desktop' : parsed.detectedPlatform;
return {
clientOs: parsed.clientOs,
clientPlatform,
};
}