mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
refactor(ban): drop ipinfo cgnat blast-radius guard (#3062)
This commit is contained in:
1 parent
ca719e7b5e
commit
af49cd6cc4
45 files changed
+10
-2153
No files matched your search
@@ -1,60 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
|
||||
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
|
||||
|
||||
const TABLE = 'ipinfo_cache';
|
||||
const SELECT_CQL = `SELECT payload FROM ${TABLE} WHERE cache_key = :cache_key LIMIT 1;`;
|
||||
const INSERT_WITH_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload) USING TTL :ttl;`;
|
||||
const INSERT_DEFAULT_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload);`;
|
||||
|
||||
interface CassandraIpInfoCacheOptions {
|
||||
client?: ICassandraClient;
|
||||
getClient?: () => ICassandraClient;
|
||||
}
|
||||
|
||||
export function createCassandraIpInfoCache(options: CassandraIpInfoCacheOptions): IpInfoCache {
|
||||
return {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const client = options.client ?? options.getClient?.();
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
const result = await client.execute({cql: SELECT_CQL, params: {cache_key: key}});
|
||||
const row = result.first();
|
||||
if (!row) return null;
|
||||
const payload = row.get('payload');
|
||||
if (typeof payload !== 'string') return null;
|
||||
return JSON.parse(payload) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
let payload: string;
|
||||
try {
|
||||
payload = JSON.stringify(value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const client = options.client ?? options.getClient?.();
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
if (ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0) {
|
||||
await client.execute({
|
||||
cql: INSERT_WITH_TTL_CQL,
|
||||
params: {cache_key: key, payload, ttl: ttlSeconds},
|
||||
});
|
||||
} else {
|
||||
await client.execute({
|
||||
cql: INSERT_DEFAULT_TTL_CQL,
|
||||
params: {cache_key: key, payload},
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
|
||||
import type {IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
|
||||
|
||||
const TABLE = 'ipinfo_requests_by_hour';
|
||||
const INSERT_CQL = `INSERT INTO ${TABLE} (
|
||||
bucket_date,
|
||||
bucket_hour,
|
||||
requested_at,
|
||||
event_id,
|
||||
source,
|
||||
reason,
|
||||
ip,
|
||||
cache_key,
|
||||
request_url,
|
||||
http_status,
|
||||
outcome,
|
||||
available,
|
||||
risk_note,
|
||||
latency_ms,
|
||||
response_ip,
|
||||
country_code,
|
||||
asn,
|
||||
is_anonymous,
|
||||
is_tor,
|
||||
is_vpn,
|
||||
is_proxy,
|
||||
is_residential_proxy,
|
||||
metadata_json
|
||||
) VALUES (
|
||||
:bucket_date,
|
||||
:bucket_hour,
|
||||
:requested_at,
|
||||
:event_id,
|
||||
:source,
|
||||
:reason,
|
||||
:ip,
|
||||
:cache_key,
|
||||
:request_url,
|
||||
:http_status,
|
||||
:outcome,
|
||||
:available,
|
||||
:risk_note,
|
||||
:latency_ms,
|
||||
:response_ip,
|
||||
:country_code,
|
||||
:asn,
|
||||
:is_anonymous,
|
||||
:is_tor,
|
||||
:is_vpn,
|
||||
:is_proxy,
|
||||
:is_residential_proxy,
|
||||
:metadata_json
|
||||
);`;
|
||||
|
||||
interface CassandraIpInfoRequestAuditOptions {
|
||||
client?: ICassandraClient;
|
||||
getClient?: () => ICassandraClient;
|
||||
}
|
||||
|
||||
export function createCassandraIpInfoRequestAuditLogger(
|
||||
options: CassandraIpInfoRequestAuditOptions,
|
||||
): IpInfoRequestAuditLogger {
|
||||
return {
|
||||
async record(event: IpInfoRequestAuditEvent): Promise<void> {
|
||||
try {
|
||||
const client = options.client ?? options.getClient?.();
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await client.execute({
|
||||
cql: INSERT_CQL,
|
||||
params: {
|
||||
bucket_date: formatUtcDate(event.requestedAt),
|
||||
bucket_hour: event.requestedAt.getUTCHours(),
|
||||
requested_at: event.requestedAt,
|
||||
event_id: randomUUID(),
|
||||
source: event.source,
|
||||
reason: event.reason,
|
||||
ip: event.ip,
|
||||
cache_key: event.cacheKey,
|
||||
request_url: event.requestUrl,
|
||||
http_status: event.httpStatus,
|
||||
outcome: event.outcome,
|
||||
available: event.available,
|
||||
risk_note: event.note,
|
||||
latency_ms: event.latencyMs,
|
||||
response_ip: event.responseIp,
|
||||
country_code: event.countryCode,
|
||||
asn: event.asnNumber,
|
||||
is_anonymous: event.isAnonymous,
|
||||
is_tor: event.isTor,
|
||||
is_vpn: event.isVpn,
|
||||
is_proxy: event.isProxy,
|
||||
is_residential_proxy: event.isResidentialProxy,
|
||||
metadata_json: serializeMetadata(event.metadata),
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatUtcDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
|
||||
if (!metadata || Object.keys(metadata).length === 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(metadata);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {z} from 'zod';
|
||||
|
||||
const IPINFO_BASE_URL = 'https://api.ipinfo.io/lookup';
|
||||
const FETCH_TIMEOUT_MS = 3000;
|
||||
const CACHE_KEY_PREFIX = 'ipinfo:max:';
|
||||
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/u;
|
||||
const POSITIVE_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
const NEGATIVE_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
|
||||
const FAILURE_TTL_REQUEST_FAILED_SECONDS = 60;
|
||||
const FAILURE_TTL_HTTP_ERROR_SECONDS = 300;
|
||||
const FAILURE_TTL_QUOTA_SECONDS = 900;
|
||||
const FAILURE_TTL_SCHEMA_MISMATCH_SECONDS = 600;
|
||||
|
||||
export interface IpInfoGeoBlock {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
continent: string | null;
|
||||
continentCode: string | null;
|
||||
region: string | null;
|
||||
regionCode: string | null;
|
||||
city: string | null;
|
||||
postalCode: string | null;
|
||||
timezone: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
accuracyRadiusKm: number | null;
|
||||
}
|
||||
|
||||
export interface IpInfoAsnBlock {
|
||||
asn: string | null;
|
||||
number: number | null;
|
||||
name: string | null;
|
||||
domain: string | null;
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
export interface IpInfoMobileBlock {
|
||||
name: string | null;
|
||||
mcc: string | null;
|
||||
mnc: string | null;
|
||||
}
|
||||
|
||||
export interface IpInfoAnonymousBlock {
|
||||
isAnonymous: boolean;
|
||||
providerName: string | null;
|
||||
isVpn: boolean;
|
||||
isProxy: boolean;
|
||||
isResidentialProxy: boolean;
|
||||
isTor: boolean;
|
||||
isRelay: boolean;
|
||||
percentDaysSeen: number | null;
|
||||
}
|
||||
|
||||
export interface IpInfoFlags {
|
||||
isAnycast: boolean;
|
||||
isHosting: boolean;
|
||||
isMobile: boolean;
|
||||
isSatellite: boolean;
|
||||
}
|
||||
|
||||
export interface IpInfoLookupResult {
|
||||
ip: string;
|
||||
available: boolean;
|
||||
note: string;
|
||||
geo: IpInfoGeoBlock;
|
||||
asn: IpInfoAsnBlock;
|
||||
mobile: IpInfoMobileBlock;
|
||||
anonymous: IpInfoAnonymousBlock;
|
||||
flags: IpInfoFlags;
|
||||
}
|
||||
|
||||
export interface IpInfoCache {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CachedIpInfoFailure extends IpInfoLookupResult {
|
||||
cachedFailure: true;
|
||||
failureOutcome: 'http_error' | 'request_failed' | 'schema_mismatch';
|
||||
failureHttpStatus: number | null;
|
||||
cachedAtMs: number;
|
||||
}
|
||||
|
||||
export function isCachedIpInfoFailure(value: unknown): value is CachedIpInfoFailure {
|
||||
return typeof value === 'object' && value !== null && (value as {available?: unknown}).available === false;
|
||||
}
|
||||
|
||||
function failureCacheTtlSeconds(outcome: CachedIpInfoFailure['failureOutcome'], httpStatus: number | null): number {
|
||||
if (outcome === 'request_failed') return FAILURE_TTL_REQUEST_FAILED_SECONDS;
|
||||
if (outcome === 'schema_mismatch') return FAILURE_TTL_SCHEMA_MISMATCH_SECONDS;
|
||||
if (httpStatus === 402 || httpStatus === 403 || httpStatus === 429) return FAILURE_TTL_QUOTA_SECONDS;
|
||||
return FAILURE_TTL_HTTP_ERROR_SECONDS;
|
||||
}
|
||||
|
||||
export interface IpInfoLookupContext {
|
||||
source?: string;
|
||||
reason?: string;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
|
||||
export interface IpInfoRequestAuditEvent {
|
||||
requestedAt: Date;
|
||||
ip: string;
|
||||
cacheKey: string;
|
||||
source: string;
|
||||
reason: string | null;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch';
|
||||
httpStatus: number | null;
|
||||
available: boolean;
|
||||
note: string;
|
||||
latencyMs: number;
|
||||
requestUrl: string;
|
||||
responseIp: string | null;
|
||||
countryCode: string | null;
|
||||
asnNumber: number | null;
|
||||
isAnonymous: boolean;
|
||||
isTor: boolean;
|
||||
isVpn: boolean;
|
||||
isProxy: boolean;
|
||||
isResidentialProxy: boolean;
|
||||
}
|
||||
|
||||
export interface IpInfoRequestAuditLogger {
|
||||
record(event: IpInfoRequestAuditEvent): Promise<void>;
|
||||
}
|
||||
|
||||
interface IpInfoServiceContext {
|
||||
apiKey: string;
|
||||
cache: IpInfoCache;
|
||||
auditLogger?: IpInfoRequestAuditLogger;
|
||||
}
|
||||
|
||||
export interface IpInfoService {
|
||||
lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult>;
|
||||
}
|
||||
|
||||
const IpInfoDateSchema = z.string().regex(ISO_DATE_REGEX);
|
||||
const RawIpInfoGeoSchema = z.object({
|
||||
city: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
region_code: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
country_code: z.string().optional(),
|
||||
continent: z.string().optional(),
|
||||
continent_code: z.string().optional(),
|
||||
latitude: z.number().optional(),
|
||||
longitude: z.number().optional(),
|
||||
timezone: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
dma_code: z.string().optional(),
|
||||
geoname_id: z.string().optional(),
|
||||
radius: z.number().int().optional(),
|
||||
last_changed: IpInfoDateSchema.optional(),
|
||||
});
|
||||
const RawIpInfoAsSchema = z.object({
|
||||
asn: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
last_changed: IpInfoDateSchema.optional(),
|
||||
});
|
||||
const RawIpInfoMobileSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
mcc: z.string().optional(),
|
||||
mnc: z.string().optional(),
|
||||
});
|
||||
const RawIpInfoAnonymousSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
last_seen: IpInfoDateSchema.optional(),
|
||||
percent_days_seen: z.number().int().optional(),
|
||||
is_proxy: z.boolean().optional(),
|
||||
is_relay: z.boolean().optional(),
|
||||
is_tor: z.boolean().optional(),
|
||||
is_vpn: z.boolean().optional(),
|
||||
is_res_proxy: z.boolean().optional(),
|
||||
});
|
||||
const RawIpInfoResponseSchema = z.object({
|
||||
ip: z.string(),
|
||||
hostname: z.string().optional(),
|
||||
geo: RawIpInfoGeoSchema,
|
||||
as: RawIpInfoAsSchema,
|
||||
mobile: RawIpInfoMobileSchema.optional(),
|
||||
anonymous: RawIpInfoAnonymousSchema,
|
||||
is_anonymous: z.boolean().optional(),
|
||||
is_anycast: z.boolean().optional(),
|
||||
is_hosting: z.boolean().optional(),
|
||||
is_mobile: z.boolean().optional(),
|
||||
is_satellite: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type RawIpInfoResponse = z.infer<typeof RawIpInfoResponseSchema>;
|
||||
|
||||
export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
|
||||
const inflight: Map<string, Promise<IpInfoLookupResult>> = new Map();
|
||||
return {
|
||||
async lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult> {
|
||||
const cacheKey = `${CACHE_KEY_PREFIX}${getSameIpDecisionKey(ip) ?? ip}`;
|
||||
const cached = await ctx.cache.get<IpInfoLookupResult>(cacheKey);
|
||||
if (cached !== null) {
|
||||
if (isCachedIpInfoFailure(cached)) {
|
||||
return unavailable(ip, cached.note);
|
||||
}
|
||||
return {...cached, ip};
|
||||
}
|
||||
const existing = inflight.get(cacheKey);
|
||||
if (existing) {
|
||||
const result = await existing;
|
||||
return {...result, ip};
|
||||
}
|
||||
const requestedAt = new Date();
|
||||
const startedAt = Date.now();
|
||||
const requestUrl = `${IPINFO_BASE_URL}/${encodeURIComponent(ip)}`;
|
||||
const fetchUrl = `${requestUrl}?token=${encodeURIComponent(ctx.apiKey)}`;
|
||||
const finalize = async (params: {
|
||||
result: IpInfoLookupResult;
|
||||
outcome: IpInfoRequestAuditEvent['outcome'];
|
||||
httpStatus: number | null;
|
||||
}): Promise<IpInfoLookupResult> => {
|
||||
await ctx.auditLogger
|
||||
?.record({
|
||||
requestedAt,
|
||||
ip,
|
||||
cacheKey,
|
||||
source: context?.source ?? 'unknown',
|
||||
reason: context?.reason ?? null,
|
||||
metadata: context?.metadata,
|
||||
outcome: params.outcome,
|
||||
httpStatus: params.httpStatus,
|
||||
available: params.result.available,
|
||||
note: params.result.note,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
requestUrl,
|
||||
responseIp: params.result.available ? params.result.ip : null,
|
||||
countryCode: params.result.geo.countryCode,
|
||||
asnNumber: params.result.asn.number,
|
||||
isAnonymous: params.result.anonymous.isAnonymous,
|
||||
isTor: params.result.anonymous.isTor,
|
||||
isVpn: params.result.anonymous.isVpn,
|
||||
isProxy: params.result.anonymous.isProxy,
|
||||
isResidentialProxy: params.result.anonymous.isResidentialProxy,
|
||||
})
|
||||
.catch(() => {});
|
||||
return params.result;
|
||||
};
|
||||
const performLookup = async (): Promise<IpInfoLookupResult> => {
|
||||
const finalizeFailure = async (params: {
|
||||
result: IpInfoLookupResult;
|
||||
outcome: CachedIpInfoFailure['failureOutcome'];
|
||||
httpStatus: number | null;
|
||||
}): Promise<IpInfoLookupResult> => {
|
||||
const entry: CachedIpInfoFailure = {
|
||||
...params.result,
|
||||
cachedFailure: true,
|
||||
failureOutcome: params.outcome,
|
||||
failureHttpStatus: params.httpStatus,
|
||||
cachedAtMs: Date.now(),
|
||||
};
|
||||
await ctx.cache
|
||||
.set(cacheKey, entry, failureCacheTtlSeconds(params.outcome, params.httpStatus))
|
||||
.catch(() => {});
|
||||
return finalize(params);
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'));
|
||||
}, FETCH_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
let payload: unknown;
|
||||
try {
|
||||
const res = await fetch(fetchUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {Accept: 'application/json'},
|
||||
});
|
||||
if (!res.ok) {
|
||||
return finalizeFailure({
|
||||
result: unavailable(ip, `IPInfo HTTP ${res.status}`),
|
||||
outcome: 'http_error',
|
||||
httpStatus: res.status,
|
||||
});
|
||||
}
|
||||
payload = await res.json();
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
return finalizeFailure({
|
||||
result: unavailable(ip, `IPInfo request failed: ${detail}`),
|
||||
outcome: 'request_failed',
|
||||
httpStatus: null,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
}
|
||||
const parsedResponse = RawIpInfoResponseSchema.safeParse(payload);
|
||||
if (!parsedResponse.success) {
|
||||
return finalizeFailure({
|
||||
result: unavailable(ip, formatSchemaMismatch(parsedResponse.error)),
|
||||
outcome: 'schema_mismatch',
|
||||
httpStatus: 200,
|
||||
});
|
||||
}
|
||||
const result = parseIpInfoResponse(parsedResponse.data);
|
||||
const ttl = result.anonymous.isAnonymous ? POSITIVE_CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS;
|
||||
await ctx.cache.set(cacheKey, result, ttl).catch(() => {});
|
||||
return finalize({
|
||||
result,
|
||||
outcome: 'http_success',
|
||||
httpStatus: 200,
|
||||
});
|
||||
};
|
||||
const promise: Promise<IpInfoLookupResult> = performLookup().finally(() => {
|
||||
if (inflight.get(cacheKey) === promise) {
|
||||
inflight.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
inflight.set(cacheKey, promise);
|
||||
return promise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createUnavailableIpInfoService(reason = 'IPInfo not configured'): IpInfoService {
|
||||
return {
|
||||
async lookup(ip: string): Promise<IpInfoLookupResult> {
|
||||
return unavailable(ip, reason);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function unavailable(ip: string, reason: string): IpInfoLookupResult {
|
||||
return {
|
||||
ip,
|
||||
available: false,
|
||||
note: reason,
|
||||
geo: emptyGeo(),
|
||||
asn: emptyAsn(),
|
||||
mobile: emptyMobile(),
|
||||
anonymous: emptyAnonymous(),
|
||||
flags: emptyFlags(),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyGeo(): IpInfoGeoBlock {
|
||||
return {
|
||||
countryCode: null,
|
||||
countryName: null,
|
||||
continent: null,
|
||||
continentCode: null,
|
||||
region: null,
|
||||
regionCode: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
timezone: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadiusKm: null,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyAsn(): IpInfoAsnBlock {
|
||||
return {asn: null, number: null, name: null, domain: null, type: null};
|
||||
}
|
||||
|
||||
function emptyMobile(): IpInfoMobileBlock {
|
||||
return {name: null, mcc: null, mnc: null};
|
||||
}
|
||||
|
||||
function emptyAnonymous(): IpInfoAnonymousBlock {
|
||||
return {
|
||||
isAnonymous: false,
|
||||
providerName: null,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyFlags(): IpInfoFlags {
|
||||
return {isAnycast: false, isHosting: false, isMobile: false, isSatellite: false};
|
||||
}
|
||||
|
||||
function parseIpInfoResponse(raw: RawIpInfoResponse): IpInfoLookupResult {
|
||||
const geo = raw.geo;
|
||||
const anon = raw.anonymous;
|
||||
const isAnonymous =
|
||||
raw.is_anonymous === true ||
|
||||
anon.is_res_proxy === true ||
|
||||
anon.is_vpn === true ||
|
||||
anon.is_proxy === true ||
|
||||
anon.is_tor === true ||
|
||||
anon.is_relay === true;
|
||||
return {
|
||||
ip: raw.ip,
|
||||
available: true,
|
||||
note: describeAnonymity(isAnonymous, anon),
|
||||
geo: {
|
||||
countryCode: normalizeCountryCode(geo.country_code),
|
||||
countryName: geo.country ?? null,
|
||||
continent: geo?.continent ?? null,
|
||||
continentCode: normalizeContinentCode(geo.continent_code),
|
||||
region: geo.region ?? null,
|
||||
regionCode: normalizeRegionCode(geo.region_code),
|
||||
city: geo.city ?? null,
|
||||
postalCode: geo.postal_code ?? null,
|
||||
timezone: geo.timezone ?? null,
|
||||
latitude: normalizeCoordinate(geo.latitude),
|
||||
longitude: normalizeCoordinate(geo.longitude),
|
||||
accuracyRadiusKm: typeof geo?.radius === 'number' && Number.isFinite(geo.radius) ? geo.radius : null,
|
||||
},
|
||||
asn: parseAsnBlock(raw.as),
|
||||
mobile: {
|
||||
name: raw.mobile?.name ?? null,
|
||||
mcc: raw.mobile?.mcc ?? null,
|
||||
mnc: raw.mobile?.mnc ?? null,
|
||||
},
|
||||
anonymous: {
|
||||
isAnonymous,
|
||||
providerName: anon?.name ?? null,
|
||||
isVpn: anon?.is_vpn === true,
|
||||
isProxy: anon?.is_proxy === true,
|
||||
isResidentialProxy: anon?.is_res_proxy === true,
|
||||
isTor: anon?.is_tor === true,
|
||||
isRelay: anon?.is_relay === true,
|
||||
percentDaysSeen: typeof anon?.percent_days_seen === 'number' ? anon.percent_days_seen : null,
|
||||
},
|
||||
flags: {
|
||||
isAnycast: raw.is_anycast === true,
|
||||
isHosting: raw.is_hosting === true,
|
||||
isMobile: raw.is_mobile === true,
|
||||
isSatellite: raw.is_satellite === true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatSchemaMismatch(error: z.ZodError): string {
|
||||
const issue = error.issues[0];
|
||||
if (!issue) {
|
||||
return 'IPInfo response schema mismatch';
|
||||
}
|
||||
const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
|
||||
return `IPInfo response schema mismatch at ${path}: ${issue.message}`;
|
||||
}
|
||||
|
||||
function parseAsnBlock(as: RawIpInfoResponse['as']): IpInfoAsnBlock {
|
||||
const raw = as?.asn ?? null;
|
||||
const numeric = raw ? Number(raw.replace(/^AS/i, '')) : Number.NaN;
|
||||
return {
|
||||
asn: raw,
|
||||
number: Number.isFinite(numeric) ? numeric : null,
|
||||
name: as?.name ?? null,
|
||||
domain: as?.domain ?? null,
|
||||
type: as?.type ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function describeAnonymity(isAnonymous: boolean, anon: RawIpInfoResponse['anonymous']): string {
|
||||
if (!isAnonymous) {
|
||||
return 'IPInfo: IP is not anonymous';
|
||||
}
|
||||
if (!anon) {
|
||||
return 'IPInfo: anonymous IP';
|
||||
}
|
||||
const flags: Array<string> = [];
|
||||
if (anon.is_res_proxy) flags.push('residential proxy');
|
||||
if (anon.is_vpn) flags.push('VPN');
|
||||
if (anon.is_proxy) flags.push('proxy');
|
||||
if (anon.is_tor) flags.push('Tor');
|
||||
if (anon.is_relay) flags.push('relay');
|
||||
const provider = anon.name ? ` (provider: ${anon.name})` : '';
|
||||
const seen = anon.percent_days_seen != null ? `, seen ${anon.percent_days_seen}% of days` : '';
|
||||
return `IPInfo: anonymous IP${provider} — ${flags.join(', ')}${seen}`;
|
||||
}
|
||||
|
||||
function normalizeCountryCode(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeContinentCode(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeRegionCode(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeCoordinate(value: number | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import type {IpInfoCache, IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
|
||||
|
||||
interface PostgresIpInfoOptions {
|
||||
client?: IPostgresClient;
|
||||
getClient?: () => IPostgresClient;
|
||||
onError?: (error: unknown, operation: string) => void;
|
||||
}
|
||||
|
||||
const VALUE_SEPARATOR = '\u001f';
|
||||
export const IPINFO_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
|
||||
export const IPINFO_REQUEST_AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
|
||||
function getClient(options: PostgresIpInfoOptions): IPostgresClient | null {
|
||||
return options.client ?? options.getClient?.() ?? null;
|
||||
}
|
||||
|
||||
function valueKey(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function rowKey(values: ReadonlyArray<unknown>): string {
|
||||
return values.map(valueKey).join(VALUE_SEPARATOR);
|
||||
}
|
||||
|
||||
function table(client: IPostgresClient): string {
|
||||
return quoteIdentifier(client.kvTable());
|
||||
}
|
||||
|
||||
async function upsertKvRow(
|
||||
client: IPostgresClient,
|
||||
tableName: string,
|
||||
partitionKey: string,
|
||||
key: string,
|
||||
row: Record<string, unknown>,
|
||||
ttlSeconds: number,
|
||||
): Promise<void> {
|
||||
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
|
||||
await client.query(
|
||||
`INSERT INTO ${table(client)} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
ON CONFLICT (table_name, row_key)
|
||||
DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = EXCLUDED.row_data, expires_at = EXCLUDED.expires_at, updated_at = now()`,
|
||||
[tableName, partitionKey, key, JSON.stringify(row), expiresAt],
|
||||
);
|
||||
}
|
||||
|
||||
export function createPostgresIpInfoCache(options: PostgresIpInfoOptions): IpInfoCache {
|
||||
return {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const client = getClient(options);
|
||||
if (!client) return null;
|
||||
const result = await client.query<{row_data: {payload?: string}}>(
|
||||
`SELECT row_data FROM ${table(client)} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
|
||||
['ipinfo_cache', rowKey([key])],
|
||||
);
|
||||
const payload = result.rows[0]?.row_data?.payload;
|
||||
return typeof payload === 'string' ? (JSON.parse(payload) as T) : null;
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_cache_get');
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
let payload: string;
|
||||
try {
|
||||
payload = JSON.stringify(value);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_cache_serialize');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const client = getClient(options);
|
||||
if (!client) return;
|
||||
await upsertKvRow(
|
||||
client,
|
||||
'ipinfo_cache',
|
||||
rowKey([key]),
|
||||
rowKey([key]),
|
||||
{cache_key: key, payload},
|
||||
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : IPINFO_CACHE_TTL_SECONDS,
|
||||
);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_cache_set');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOptions): IpInfoRequestAuditLogger {
|
||||
return {
|
||||
async record(event: IpInfoRequestAuditEvent): Promise<void> {
|
||||
try {
|
||||
const client = getClient(options);
|
||||
if (!client) return;
|
||||
const bucketDate = formatUtcDate(event.requestedAt);
|
||||
const bucketHour = event.requestedAt.getUTCHours();
|
||||
const eventId = randomUUID();
|
||||
await upsertKvRow(
|
||||
client,
|
||||
'ipinfo_requests_by_hour',
|
||||
rowKey([bucketDate, bucketHour]),
|
||||
rowKey([bucketDate, bucketHour, event.requestedAt.toISOString(), eventId]),
|
||||
{
|
||||
bucket_date: bucketDate,
|
||||
bucket_hour: bucketHour,
|
||||
requested_at: event.requestedAt.toISOString(),
|
||||
event_id: eventId,
|
||||
source: event.source,
|
||||
reason: event.reason,
|
||||
ip: event.ip,
|
||||
cache_key: event.cacheKey,
|
||||
request_url: event.requestUrl,
|
||||
http_status: event.httpStatus,
|
||||
outcome: event.outcome,
|
||||
available: event.available,
|
||||
risk_note: event.note,
|
||||
latency_ms: event.latencyMs,
|
||||
response_ip: event.responseIp,
|
||||
country_code: event.countryCode,
|
||||
asn: event.asnNumber,
|
||||
is_anonymous: event.isAnonymous,
|
||||
is_tor: event.isTor,
|
||||
is_vpn: event.isVpn,
|
||||
is_proxy: event.isProxy,
|
||||
is_residential_proxy: event.isResidentialProxy,
|
||||
metadata_json: serializeMetadata(event.metadata),
|
||||
},
|
||||
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
|
||||
);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_request_audit_record');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatUtcDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
|
||||
if (!metadata || Object.keys(metadata).length === 0) return null;
|
||||
try {
|
||||
return JSON.stringify(metadata);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
|
||||
|
||||
const DEFAULT_HOT_TTL_SECONDS = 10 * 60;
|
||||
|
||||
interface TieredIpInfoCacheOptions {
|
||||
hot: IpInfoCache;
|
||||
cold: IpInfoCache;
|
||||
hotTtlSeconds?: number;
|
||||
skipColdWrite?: (value: unknown) => boolean;
|
||||
}
|
||||
|
||||
export function createTieredIpInfoCache(opts: TieredIpInfoCacheOptions): IpInfoCache {
|
||||
const hotTtl = opts.hotTtlSeconds ?? DEFAULT_HOT_TTL_SECONDS;
|
||||
return {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const hit = await opts.hot.get<T>(key).catch(() => null);
|
||||
if (hit !== null) return hit;
|
||||
const cold = await opts.cold.get<T>(key).catch(() => null);
|
||||
if (cold === null) return null;
|
||||
if (opts.skipColdWrite?.(cold) === true) return cold;
|
||||
void opts.hot.set(key, cold, hotTtl).catch(() => {});
|
||||
return cold;
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
const effectiveHotTtl = Math.max(1, Math.min(hotTtl, ttlSeconds ?? hotTtl));
|
||||
const writes: Array<Promise<void>> = [opts.hot.set(key, value, effectiveHotTtl).catch(() => {})];
|
||||
if (opts.skipColdWrite?.(value) !== true) {
|
||||
writes.push(opts.cold.set(key, value, ttlSeconds).catch(() => {}));
|
||||
}
|
||||
await Promise.all(writes);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user