From af49cd6cc428bcd417ec334f20626fdffbb355a2 Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 30 Sep 2026 16:54:43 +0200 Subject: [PATCH] refactor(ban): drop ipinfo cgnat blast-radius guard (#3062) --- deploy/self-hosting/.env.example | 1 - deploy/self-hosting/docker-compose.yml | 1 - fluxer_admin/openapi-admin.json | 2 +- .../pkgs/geoip/src/CassandraIpInfoCache.ts | 60 --- .../geoip/src/CassandraIpInfoRequestAudit.ts | 119 ---- fluxer_api/pkgs/geoip/src/IpInfoService.ts | 506 ------------------ fluxer_api/pkgs/geoip/src/PostgresIpInfoKv.ts | 153 ------ .../pkgs/geoip/src/TieredIpInfoCache.ts | 35 -- fluxer_api/src/api/Config.ts | 3 - fluxer_api/src/api/admin/AdminService.ts | 3 - .../admin/controllers/BanAdminController.ts | 2 +- .../services/AdminBanManagementService.ts | 37 -- .../tests/AdminBanManagementIpDecline.test.ts | 170 ------ .../admin/tests/AdminDeletionQueue.test.ts | 59 -- fluxer_api/src/api/ban/IpBanCgnatGuard.ts | 80 --- fluxer_api/src/api/ban/IpInfoCacheFactory.ts | 45 -- .../api/ban/__tests__/IpBanCgnatGuard.test.ts | 162 ------ .../ban/__tests__/IpInfoServiceCache.test.ts | 210 -------- .../ban/__tests__/PostgresIpInfoKv.test.ts | 75 --- .../ban/__tests__/TieredIpInfoCache.test.ts | 130 ----- fluxer_api/src/api/config/APIConfig.ts | 3 - .../CassandraDefaultTtlParity.test.ts | 3 - .../api/database/PostgresKvDefaultTtl.test.ts | 2 - .../database/PostgresKvDefaultTtlExpiry.ts | 3 - .../api/guild/services/GuildMemberService.ts | 4 +- .../guild/services/GuildModerationService.ts | 34 +- .../src/api/guild/services/GuildService.ts | 4 - .../member/GuildMemberValidationService.ts | 35 +- .../middleware/GuildStackServiceFactory.ts | 3 - .../src/api/middleware/ServiceMiddleware.ts | 32 -- fluxer_api/src/api/test/ApiTestHarness.ts | 3 - .../api/test/msw/handlers/IpInfoHandlers.ts | 28 - fluxer_api/src/api/test/msw/server.ts | 2 - .../src/api/worker/WorkerDependencies.ts | 3 - .../tasks/admin_bulk/AdminBulkServices.ts | 3 +- .../worker/tests/AdminBulkGuildTasks.test.ts | 2 - .../src/content/docs/admin-api/blocklists.mdx | 6 +- .../src/content/docs/admin-api/index.mdx | 1 - .../content/docs/operator/configuration.mdx | 6 +- packages/config/src/ConfigLoader.ts | 3 - packages/config/src/MasterConfig.ts | 3 - .../config/src/__tests__/ConfigLoader.test.ts | 8 - .../__tests__/EnvironmentOverrides.test.ts | 3 - .../src/config_loader/EnvironmentOverrides.ts | 2 - tools/dev/cassandra_target_schema.json | 114 ---- 45 files changed, 10 insertions(+), 2153 deletions(-) delete mode 100644 fluxer_api/pkgs/geoip/src/CassandraIpInfoCache.ts delete mode 100644 fluxer_api/pkgs/geoip/src/CassandraIpInfoRequestAudit.ts delete mode 100644 fluxer_api/pkgs/geoip/src/IpInfoService.ts delete mode 100644 fluxer_api/pkgs/geoip/src/PostgresIpInfoKv.ts delete mode 100644 fluxer_api/pkgs/geoip/src/TieredIpInfoCache.ts delete mode 100644 fluxer_api/src/api/admin/tests/AdminBanManagementIpDecline.test.ts delete mode 100644 fluxer_api/src/api/ban/IpBanCgnatGuard.ts delete mode 100644 fluxer_api/src/api/ban/IpInfoCacheFactory.ts delete mode 100644 fluxer_api/src/api/ban/__tests__/IpBanCgnatGuard.test.ts delete mode 100644 fluxer_api/src/api/ban/__tests__/IpInfoServiceCache.test.ts delete mode 100644 fluxer_api/src/api/ban/__tests__/PostgresIpInfoKv.test.ts delete mode 100644 fluxer_api/src/api/ban/__tests__/TieredIpInfoCache.test.ts delete mode 100644 fluxer_api/src/api/test/msw/handlers/IpInfoHandlers.ts diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example index e3d4d24d7..1b4f81224 100644 --- a/deploy/self-hosting/.env.example +++ b/deploy/self-hosting/.env.example @@ -160,7 +160,6 @@ MEILI_MASTER_KEY=CHANGE_ME # api.pwnedpasswords.com. #FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=false #FLUXER_BLOCKLIST_FEEDS_ENABLED=false -#FLUXER_IPINFO_API_KEY= # A local path, or an s3:// URL read with the S3 credentials of this file. #FLUXER_GEOIP_DB_PATH= diff --git a/deploy/self-hosting/docker-compose.yml b/deploy/self-hosting/docker-compose.yml index dd82a43df..2f42dfdcc 100644 --- a/deploy/self-hosting/docker-compose.yml +++ b/deploy/self-hosting/docker-compose.yml @@ -33,7 +33,6 @@ x-fluxer-env: &fluxer-env FLUXER_APP_ORIGIN_ALIASES: ${FLUXER_APP_ORIGIN_ALIASES:-} FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: ${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-} FLUXER_BLOCKLIST_FEEDS_ENABLED: ${FLUXER_BLOCKLIST_FEEDS_ENABLED:-} - FLUXER_IPINFO_API_KEY: ${FLUXER_IPINFO_API_KEY:-} FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-} FLUXER_API_ENDPOINT: ${FLUXER_API_ENDPOINT:-} diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 1d2786c2e..69c5876ec 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -1251,7 +1251,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list, or that IPInfo reports as a high blast-radius carrier NAT, is refused with 400 IP_BAN_DECLINED and recorded in the audit log.", + "description": "Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list is refused with 400 IP_BAN_DECLINED and recorded in the audit log.", "security": [{"adminApiKey": []}], "parameters": [ { diff --git a/fluxer_api/pkgs/geoip/src/CassandraIpInfoCache.ts b/fluxer_api/pkgs/geoip/src/CassandraIpInfoCache.ts deleted file mode 100644 index bb5f6785d..000000000 --- a/fluxer_api/pkgs/geoip/src/CassandraIpInfoCache.ts +++ /dev/null @@ -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(key: string): Promise { - 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(key: string, value: T, ttlSeconds?: number): Promise { - 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 {} - }, - }; -} diff --git a/fluxer_api/pkgs/geoip/src/CassandraIpInfoRequestAudit.ts b/fluxer_api/pkgs/geoip/src/CassandraIpInfoRequestAudit.ts deleted file mode 100644 index f20efbc90..000000000 --- a/fluxer_api/pkgs/geoip/src/CassandraIpInfoRequestAudit.ts +++ /dev/null @@ -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 { - 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; - } -} diff --git a/fluxer_api/pkgs/geoip/src/IpInfoService.ts b/fluxer_api/pkgs/geoip/src/IpInfoService.ts deleted file mode 100644 index f581355b2..000000000 --- a/fluxer_api/pkgs/geoip/src/IpInfoService.ts +++ /dev/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(key: string): Promise; - set(key: string, value: T, ttlSeconds?: number): Promise; -} - -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; -} - -export interface IpInfoRequestAuditEvent { - requestedAt: Date; - ip: string; - cacheKey: string; - source: string; - reason: string | null; - metadata?: Record; - 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; -} - -interface IpInfoServiceContext { - apiKey: string; - cache: IpInfoCache; - auditLogger?: IpInfoRequestAuditLogger; -} - -export interface IpInfoService { - lookup(ip: string, context?: IpInfoLookupContext): Promise; -} - -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; - -export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService { - const inflight: Map> = new Map(); - return { - async lookup(ip: string, context?: IpInfoLookupContext): Promise { - const cacheKey = `${CACHE_KEY_PREFIX}${getSameIpDecisionKey(ip) ?? ip}`; - const cached = await ctx.cache.get(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 => { - 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 => { - const finalizeFailure = async (params: { - result: IpInfoLookupResult; - outcome: CachedIpInfoFailure['failureOutcome']; - httpStatus: number | null; - }): Promise => { - 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 = 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 { - 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('.') : ''; - 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 = []; - 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; -} diff --git a/fluxer_api/pkgs/geoip/src/PostgresIpInfoKv.ts b/fluxer_api/pkgs/geoip/src/PostgresIpInfoKv.ts deleted file mode 100644 index 19d65db69..000000000 --- a/fluxer_api/pkgs/geoip/src/PostgresIpInfoKv.ts +++ /dev/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): 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, - ttlSeconds: number, -): Promise { - 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(key: string): Promise { - 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(key: string, value: T, ttlSeconds?: number): Promise { - 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 { - 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; - } -} diff --git a/fluxer_api/pkgs/geoip/src/TieredIpInfoCache.ts b/fluxer_api/pkgs/geoip/src/TieredIpInfoCache.ts deleted file mode 100644 index e9358c775..000000000 --- a/fluxer_api/pkgs/geoip/src/TieredIpInfoCache.ts +++ /dev/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(key: string): Promise { - const hit = await opts.hot.get(key).catch(() => null); - if (hit !== null) return hit; - const cold = await opts.cold.get(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(key: string, value: T, ttlSeconds?: number): Promise { - const effectiveHotTtl = Math.max(1, Math.min(hotTtl, ttlSeconds ?? hotTtl)); - const writes: Array> = [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); - }, - }; -} diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index dce0b9451..0811f06d5 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -259,9 +259,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { } : undefined, }, - ipinfo: { - apiKey: master.integrations.ipinfo.api_key || undefined, - }, blocklistFeeds: { enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted, }, diff --git a/fluxer_api/src/api/admin/AdminService.ts b/fluxer_api/src/api/admin/AdminService.ts index 43342de27..1f339e494 100644 --- a/fluxer_api/src/api/admin/AdminService.ts +++ b/fluxer_api/src/api/admin/AdminService.ts @@ -41,7 +41,6 @@ import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlem import type {UserService} from '@app/api/user/services/UserService'; import type {VoiceRepository} from '@app/api/voice/VoiceRepository'; import type {SendSystemDmResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas'; -import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService'; import type Stripe from 'stripe'; export class AdminService { @@ -81,7 +80,6 @@ export class AdminService { private readonly applicationRepository: IApplicationRepository, private readonly stripe: Stripe | null = null, private readonly jobLedger: IJobLedgerRepository, - private readonly ipInfoService: IpInfoService, private readonly storeEntitlementService: StoreEntitlementService, ) { const {users, gateway, worker, snowflake} = this.apiContext.services; @@ -94,7 +92,6 @@ export class AdminService { apiContext: this.apiContext, adminRepository: this.adminRepository, auditService: this.auditService, - ipInfoService: this.ipInfoService, }); this.userService = new AdminUserService({ apiContext: this.apiContext, diff --git a/fluxer_api/src/api/admin/controllers/BanAdminController.ts b/fluxer_api/src/api/admin/controllers/BanAdminController.ts index e7e6b9c89..25b7c0787 100644 --- a/fluxer_api/src/api/admin/controllers/BanAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/BanAdminController.ts @@ -338,7 +338,7 @@ export function BanAdminController(app: HonoApp) { tags: ['Admin'], requestSchema: AdminBlocklistEntryCreateRequest, description: - 'Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list, or that IPInfo reports as a high blast-radius carrier NAT, is refused with 400 IP_BAN_DECLINED and recorded in the audit log.', + 'Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list is refused with 400 IP_BAN_DECLINED and recorded in the audit log.', }), async (ctx) => { const adminService = ctx.get('adminService'); diff --git a/fluxer_api/src/api/admin/services/AdminBanManagementService.ts b/fluxer_api/src/api/admin/services/AdminBanManagementService.ts index 0e87ace6e..dfcbf2e9d 100644 --- a/fluxer_api/src/api/admin/services/AdminBanManagementService.ts +++ b/fluxer_api/src/api/admin/services/AdminBanManagementService.ts @@ -4,7 +4,6 @@ import type {ApiContext} from '@app/api/ApiContext'; import type {IAdminRepository} from '@app/api/admin/IAdminRepository'; import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService'; import {createUserID, type UserID} from '@app/api/BrandedTypes'; -import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard'; import {isIpBanExempt} from '@app/api/ban/IpBanExemptions'; import { BANNED_AVATAR_HASHES_REFRESH_CHANNEL, @@ -18,7 +17,6 @@ import { } from '@app/api/constants/ContentModeration'; import {IP_BAN_REFRESH_CHANNEL} from '@app/api/constants/IpBan'; import type {BannedProfileSubstringScope} from '@app/api/database/types/AdminArchiveTypes'; -import {Logger} from '@app/api/Logger'; import {bannedAvatarHashCache} from '@app/api/middleware/BannedAvatarHashCache'; import {fileShaCache} from '@app/api/middleware/FileShaCache'; import {ipBanCache} from '@app/api/middleware/IpBanMiddleware'; @@ -34,13 +32,11 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError'; import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; import type {AdminBlocklistListType} from '@fluxer/schema/src/domains/admin/AdminBlocklistSchemas'; -import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService'; interface AdminBanManagementServiceDeps { apiContext: ApiContext; adminRepository: IAdminRepository; auditService: AdminAuditService; - ipInfoService: IpInfoService; } interface AdminBlocklistEntry { @@ -146,20 +142,6 @@ export class AdminBanManagementService { message: 'This IP address is on the instance exemption list', }); } - if (await this.shouldSkipIpBanForCgnat(data.ip)) { - await auditService.createAuditLog({ - adminUserId, - targetType: 'ip', - targetId: BigInt(0), - action: 'ban_ip_skipped_cgnat', - auditLogReason, - metadata: new Map([['ip', data.ip]]), - }); - throw new BadRequestError({ - code: APIErrorCodes.IP_BAN_DECLINED, - message: 'This IP address is a high blast-radius carrier network', - }); - } await adminRepository.banIp(data.ip); ipBanCache.ban(data.ip); await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh'); @@ -200,25 +182,6 @@ export class AdminBanManagementService { return {banned}; } - private async shouldSkipIpBanForCgnat(ip: string): Promise { - if (!isSingleIpBanCandidate(ip)) { - return false; - } - try { - const {cgnat: highRisk} = await getIpBanBlastRadiusVerdict(ip, this.deps.ipInfoService, { - source: 'admin.ip_ban', - reason: 'pre_write_cgnat_guard', - }); - if (highRisk) { - Logger.warn({ip}, 'Skipping IP ban because IPInfo indicates high CGNAT blast-radius risk'); - } - return highRisk; - } catch (error) { - Logger.warn({error, ip}, 'IPInfo CGNAT guard failed while adding IP ban'); - return false; - } - } - async banEmail( data: { email: string; diff --git a/fluxer_api/src/api/admin/tests/AdminBanManagementIpDecline.test.ts b/fluxer_api/src/api/admin/tests/AdminBanManagementIpDecline.test.ts deleted file mode 100644 index e381dec67..000000000 --- a/fluxer_api/src/api/admin/tests/AdminBanManagementIpDecline.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import type {ApiContext} from '@app/api/ApiContext'; -import type {IAdminRepository} from '@app/api/admin/IAdminRepository'; -import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService'; -import {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService'; -import {createUserID} from '@app/api/BrandedTypes'; -import {resetIpBanExemptionsForTesting} from '@app/api/ban/IpBanExemptions'; -import {getConfig} from '@app/api/Config'; -import {ipBanCache} from '@app/api/middleware/IpBanMiddleware'; -import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; -import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; -import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService'; -import {afterEach, beforeEach, describe, expect, it} from 'vitest'; - -const ADMIN_ID = createUserID(42n); -const EXEMPT_IP = '10.0.0.1'; -const CARRIER_IP = '198.51.100.7'; -const LOOKUP_FAILURE_IP = '203.0.113.9'; - -interface AuditCall { - action: string; - metadata: Map | undefined; -} - -function ipInfoResult(overrides: Partial = {}): IpInfoLookupResult { - return { - ip: CARRIER_IP, - available: true, - note: 'test', - geo: { - countryCode: 'US', - countryName: 'United States', - continent: 'North America', - continentCode: 'NA', - region: null, - regionCode: null, - city: null, - postalCode: null, - timezone: null, - latitude: null, - longitude: null, - accuracyRadiusKm: null, - }, - asn: { - asn: 'AS64500', - number: 64500, - name: 'Test Carrier', - domain: null, - type: null, - }, - mobile: { - name: null, - mcc: null, - mnc: null, - }, - anonymous: { - isAnonymous: false, - providerName: null, - isVpn: false, - isProxy: false, - isResidentialProxy: false, - isTor: false, - isRelay: false, - percentDaysSeen: null, - }, - flags: { - isAnycast: false, - isHosting: false, - isMobile: false, - isSatellite: false, - }, - ...overrides, - }; -} - -function createBanManagementService(lookup: (ip: string) => Promise) { - const bannedIps: Array = []; - const auditCalls: Array = []; - const adminRepository = { - banIp: async (ip: string) => { - bannedIps.push(ip); - }, - }; - const auditService = { - createAuditLog: async ({action, metadata}: AuditCall) => { - auditCalls.push({action, metadata}); - }, - }; - const ipInfoService = {lookup: (ip: string) => lookup(ip)}; - const apiContext = { - services: { - cache: { - publish: async () => {}, - }, - }, - }; - const service = new AdminBanManagementService({ - apiContext: apiContext as unknown as ApiContext, - adminRepository: adminRepository as unknown as IAdminRepository, - auditService: auditService as unknown as AdminAuditService, - ipInfoService: ipInfoService as unknown as IpInfoService, - }); - return {service, bannedIps, auditCalls}; -} - -describe('AdminBanManagementService banIp guards', () => { - let originalExemptIps: Array; - - beforeEach(() => { - const config = getConfig(); - originalExemptIps = config.ipBanExemptIps; - config.ipBanExemptIps = [EXEMPT_IP]; - resetIpBanExemptionsForTesting(); - }); - - afterEach(() => { - ipBanCache.unban(LOOKUP_FAILURE_IP); - getConfig().ipBanExemptIps = originalExemptIps; - resetIpBanExemptionsForTesting(); - }); - - it('refuses an exempt address with IP_BAN_DECLINED and writes no ban row', async () => { - const {service, bannedIps, auditCalls} = createBanManagementService(async () => ipInfoResult()); - - const error = await service.banIp({ip: EXEMPT_IP}, ADMIN_ID, null).then( - () => null, - (caught: unknown) => caught, - ); - - expect(error).toBeInstanceOf(BadRequestError); - expect((error as BadRequestError).code).toBe(APIErrorCodes.IP_BAN_DECLINED); - expect((error as BadRequestError).status).toBe(400); - expect(bannedIps).toEqual([]); - expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip_skipped_exempt']); - expect(auditCalls[0].metadata?.get('ip')).toBe(EXEMPT_IP); - }); - - it('refuses a high blast-radius carrier address with IP_BAN_DECLINED and writes no ban row', async () => { - const {service, bannedIps, auditCalls} = createBanManagementService(async () => - ipInfoResult({ - mobile: {name: 'Example Mobile', mcc: '001', mnc: '01'}, - flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false}, - }), - ); - - const error = await service.banIp({ip: CARRIER_IP}, ADMIN_ID, null).then( - () => null, - (caught: unknown) => caught, - ); - - expect(error).toBeInstanceOf(BadRequestError); - expect((error as BadRequestError).code).toBe(APIErrorCodes.IP_BAN_DECLINED); - expect((error as BadRequestError).status).toBe(400); - expect(bannedIps).toEqual([]); - expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip_skipped_cgnat']); - expect(auditCalls[0].metadata?.get('ip')).toBe(CARRIER_IP); - }); - - it('still writes the ban when the IPInfo lookup fails', async () => { - const {service, bannedIps, auditCalls} = createBanManagementService(async () => { - throw new Error('ipinfo is unreachable'); - }); - - await expect(service.banIp({ip: LOOKUP_FAILURE_IP}, ADMIN_ID, null)).resolves.toBeUndefined(); - - expect(bannedIps).toEqual([LOOKUP_FAILURE_IP]); - expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip']); - }); -}); diff --git a/fluxer_api/src/api/admin/tests/AdminDeletionQueue.test.ts b/fluxer_api/src/api/admin/tests/AdminDeletionQueue.test.ts index ba0981885..37cef10de 100644 --- a/fluxer_api/src/api/admin/tests/AdminDeletionQueue.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminDeletionQueue.test.ts @@ -10,83 +10,24 @@ import { type TestAccount, } from '@app/api/auth/tests/AuthTestUtils'; import {createUserID} from '@app/api/BrandedTypes'; -import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware'; import {getAdminRepository} from '@app/api/middleware/ServiceSingletons'; import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; import {HTTP_STATUS} from '@app/api/test/TestConstants'; import {createBuilder} from '@app/api/test/TestRequestBuilder'; import {UserRepository} from '@app/api/user/repositories/UserRepository'; import {DeletionReasons} from '@fluxer/constants/src/Core'; -import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService'; import {afterEach, beforeEach, describe, expect, test} from 'vitest'; function createUniqueTestIp(): string { return `198.51.${randomInt(0, 256)}.${randomInt(1, 255)}`; } -function ipInfoResult(ip: string, overrides: Partial = {}): IpInfoLookupResult { - return { - ip, - available: true, - note: 'test', - geo: { - countryCode: 'US', - countryName: 'United States', - continent: 'North America', - continentCode: 'NA', - region: null, - regionCode: null, - city: null, - postalCode: null, - timezone: null, - latitude: null, - longitude: null, - accuracyRadiusKm: null, - }, - asn: { - asn: 'AS64500', - number: 64500, - name: 'Test ISP', - domain: null, - type: null, - }, - mobile: { - name: null, - mcc: null, - mnc: null, - }, - anonymous: { - isAnonymous: false, - providerName: null, - isVpn: false, - isProxy: false, - isResidentialProxy: false, - isTor: false, - isRelay: false, - percentDaysSeen: null, - }, - flags: { - isAnycast: false, - isHosting: false, - isMobile: false, - isSatellite: false, - }, - ...overrides, - }; -} - describe('Admin Deletion Queue', () => { let harness: ApiTestHarness; beforeEach(async () => { harness = await createApiTestHarness(); - setInjectedIpInfoService({ - async lookup(ip: string) { - return ipInfoResult(ip); - }, - }); }); afterEach(async () => { - setInjectedIpInfoService(undefined); await harness?.shutdown(); }); test('admin scheduling queues deletion and rescheduling replaces the old Cassandra row', async () => { diff --git a/fluxer_api/src/api/ban/IpBanCgnatGuard.ts b/fluxer_api/src/api/ban/IpBanCgnatGuard.ts deleted file mode 100644 index 0cc4aa5f6..000000000 --- a/fluxer_api/src/api/ban/IpBanCgnatGuard.ts +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils'; -import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress'; -import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService'; - -const VERDICT_CACHE_TTL_MS = 60 * 60 * 1000; - -interface IpBanBlastRadiusVerdict { - cgnat: boolean; - sharedAccess: boolean; -} - -interface CachedVerdict { - expiresAtMs: number; - verdict: IpBanBlastRadiusVerdict; -} - -const verdictCache = new Map(); - -function isAnonymousAccess(result: IpInfoLookupResult): boolean { - return ( - result.anonymous.isAnonymous || - result.anonymous.isVpn || - result.anonymous.isProxy || - result.anonymous.isResidentialProxy || - result.anonymous.isTor || - result.anonymous.isRelay - ); -} - -export function isHighCgnatBlastRadiusRisk(result: IpInfoLookupResult): boolean { - if (!result.available || result.flags.isHosting || isAnonymousAccess(result)) { - return false; - } - const asnType = result.asn.type?.trim().toLowerCase() ?? null; - return result.flags.isMobile || result.mobile.name !== null || asnType === 'mobile'; -} - -export function isHighSharedAccessBlastRadiusRisk(result: IpInfoLookupResult): boolean { - if (result.flags.isHosting || isAnonymousAccess(result)) { - return false; - } - const asnType = result.asn.type?.trim().toLowerCase() ?? null; - return result.flags.isAnycast || result.flags.isSatellite || asnType === 'education'; -} - -export function isSingleIpBanCandidate(value: string): boolean { - return parseIpBanEntry(value)?.type === 'single'; -} - -export async function getIpBanBlastRadiusVerdict( - ip: string, - ipInfoService: IpInfoService, - context: { - source: string; - reason: string; - }, -): Promise { - const now = Date.now(); - const cacheKey = getSameIpDecisionKey(ip) ?? ip; - const cached = verdictCache.get(cacheKey); - if (cached && cached.expiresAtMs > now) { - return cached.verdict; - } - const result = await ipInfoService.lookup(ip, { - source: context.source, - reason: context.reason, - metadata: {policy: 'ip_ban_cgnat_guard'}, - }); - const verdict: IpBanBlastRadiusVerdict = { - cgnat: isHighCgnatBlastRadiusRisk(result), - sharedAccess: isHighSharedAccessBlastRadiusRisk(result), - }; - verdictCache.set(cacheKey, { - verdict, - expiresAtMs: now + VERDICT_CACHE_TTL_MS, - }); - return verdict; -} diff --git a/fluxer_api/src/api/ban/IpInfoCacheFactory.ts b/fluxer_api/src/api/ban/IpInfoCacheFactory.ts deleted file mode 100644 index 80b92e223..000000000 --- a/fluxer_api/src/api/ban/IpInfoCacheFactory.ts +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {Config} from '@app/api/Config'; -import {Logger} from '@app/api/Logger'; -import {getDefaultCassandraClient} from '@pkgs/cassandra/src/Client'; -import {createCassandraIpInfoCache} from '@pkgs/geoip/src/CassandraIpInfoCache'; -import {createCassandraIpInfoRequestAuditLogger} from '@pkgs/geoip/src/CassandraIpInfoRequestAudit'; -import {type IpInfoCache, type IpInfoRequestAuditLogger, isCachedIpInfoFailure} from '@pkgs/geoip/src/IpInfoService'; -import {createPostgresIpInfoCache, createPostgresIpInfoRequestAuditLogger} from '@pkgs/geoip/src/PostgresIpInfoKv'; -import {createTieredIpInfoCache} from '@pkgs/geoip/src/TieredIpInfoCache'; -import {getDefaultPostgresClient} from '@pkgs/postgres/src/Client'; - -interface BuildIpInfoCacheOptions { - hot: IpInfoCache; -} - -export function buildIpInfoCache(options: BuildIpInfoCacheOptions): IpInfoCache { - if (Config.database.backend === 'postgres') { - return createTieredIpInfoCache({ - hot: options.hot, - cold: createPostgresIpInfoCache({ - getClient: getDefaultPostgresClient, - onError: (error, operation) => Logger.warn({error, operation}, 'Postgres IPInfo cache operation failed'), - }), - skipColdWrite: isCachedIpInfoFailure, - }); - } - return createTieredIpInfoCache({ - hot: options.hot, - cold: createCassandraIpInfoCache({getClient: getDefaultCassandraClient}), - skipColdWrite: isCachedIpInfoFailure, - }); -} - -export function buildIpInfoRequestAuditLogger(): IpInfoRequestAuditLogger { - if (Config.database.backend === 'postgres') { - return createPostgresIpInfoRequestAuditLogger({ - getClient: getDefaultPostgresClient, - onError: (error, operation) => Logger.warn({error, operation}, 'Postgres IPInfo audit operation failed'), - }); - } - return createCassandraIpInfoRequestAuditLogger({ - getClient: getDefaultCassandraClient, - }); -} diff --git a/fluxer_api/src/api/ban/__tests__/IpBanCgnatGuard.test.ts b/fluxer_api/src/api/ban/__tests__/IpBanCgnatGuard.test.ts deleted file mode 100644 index e2980a402..000000000 --- a/fluxer_api/src/api/ban/__tests__/IpBanCgnatGuard.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import { - isHighCgnatBlastRadiusRisk, - isHighSharedAccessBlastRadiusRisk, - isSingleIpBanCandidate, -} from '@app/api/ban/IpBanCgnatGuard'; -import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService'; -import {describe, expect, it} from 'vitest'; - -function ipInfoResult(overrides: Partial = {}): IpInfoLookupResult { - return { - ip: '198.51.100.1', - available: true, - note: 'test', - geo: { - countryCode: 'US', - countryName: 'United States', - continent: 'North America', - continentCode: 'NA', - region: null, - regionCode: null, - city: null, - postalCode: null, - timezone: null, - latitude: null, - longitude: null, - accuracyRadiusKm: null, - }, - asn: { - asn: 'AS64500', - number: 64500, - name: 'Test ISP', - domain: null, - type: null, - }, - mobile: { - name: null, - mcc: null, - mnc: null, - }, - anonymous: { - isAnonymous: false, - providerName: null, - isVpn: false, - isProxy: false, - isResidentialProxy: false, - isTor: false, - isRelay: false, - percentDaysSeen: null, - }, - flags: { - isAnycast: false, - isHosting: false, - isMobile: false, - isSatellite: false, - }, - ...overrides, - }; -} - -describe('IpBanCgnatGuard', () => { - it('only treats single IP ban entries as CGNAT guard candidates', () => { - expect(isSingleIpBanCandidate('198.51.100.10')).toBe(true); - expect(isSingleIpBanCandidate('198.51.100.0/24')).toBe(false); - }); - it('flags mobile carrier IPs as high blast-radius risk', () => { - expect( - isHighCgnatBlastRadiusRisk( - ipInfoResult({ - mobile: {name: 'Example Mobile', mcc: '001', mnc: '01'}, - flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false}, - }), - ), - ).toBe(true); - }); - it('does not exempt hosting or anonymous infrastructure', () => { - expect( - isHighCgnatBlastRadiusRisk( - ipInfoResult({ - flags: {isAnycast: false, isHosting: true, isMobile: true, isSatellite: false}, - }), - ), - ).toBe(false); - expect( - isHighCgnatBlastRadiusRisk( - ipInfoResult({ - anonymous: { - isAnonymous: true, - providerName: 'Example VPN', - isVpn: true, - isProxy: false, - isResidentialProxy: false, - isTor: false, - isRelay: false, - percentDaysSeen: null, - }, - flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false}, - }), - ), - ).toBe(false); - }); - it('flags satellite, anycast and education networks as high blast-radius risk', () => { - expect( - isHighSharedAccessBlastRadiusRisk( - ipInfoResult({ - flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true}, - }), - ), - ).toBe(true); - expect( - isHighSharedAccessBlastRadiusRisk( - ipInfoResult({ - flags: {isAnycast: true, isHosting: false, isMobile: false, isSatellite: false}, - }), - ), - ).toBe(true); - expect( - isHighSharedAccessBlastRadiusRisk( - ipInfoResult({asn: {asn: 'AS64500', number: 64500, name: 'Test University', domain: null, type: 'education'}}), - ), - ).toBe(true); - }); - it('does not flag ordinary residential networks as shared-access risk', () => { - expect(isHighSharedAccessBlastRadiusRisk(ipInfoResult())).toBe(false); - }); - it('does not treat shared-access networks as CGNAT risk', () => { - expect( - isHighCgnatBlastRadiusRisk( - ipInfoResult({ - flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true}, - }), - ), - ).toBe(false); - }); - it('does not exempt hosting or anonymous shared-access infrastructure', () => { - expect( - isHighSharedAccessBlastRadiusRisk( - ipInfoResult({ - flags: {isAnycast: true, isHosting: true, isMobile: false, isSatellite: false}, - }), - ), - ).toBe(false); - expect( - isHighSharedAccessBlastRadiusRisk( - ipInfoResult({ - anonymous: { - isAnonymous: true, - providerName: 'Example VPN', - isVpn: true, - isProxy: false, - isResidentialProxy: false, - isTor: false, - isRelay: false, - percentDaysSeen: null, - }, - flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true}, - }), - ), - ).toBe(false); - }); -}); diff --git a/fluxer_api/src/api/ban/__tests__/IpInfoServiceCache.test.ts b/fluxer_api/src/api/ban/__tests__/IpInfoServiceCache.test.ts deleted file mode 100644 index f1362e724..000000000 --- a/fluxer_api/src/api/ban/__tests__/IpInfoServiceCache.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {server} from '@app/api/test/msw/server'; -import type { - CachedIpInfoFailure, - IpInfoCache, - IpInfoRequestAuditEvent, - IpInfoRequestAuditLogger, -} from '@pkgs/geoip/src/IpInfoService'; -import {createIpInfoService} from '@pkgs/geoip/src/IpInfoService'; -import {delay, HttpResponse, http} from 'msw'; -import {describe, expect, it} from 'vitest'; - -interface RecordedSet { - key: string; - value: unknown; - ttlSeconds: number | undefined; -} - -interface RecordingCache { - cache: IpInfoCache; - sets: Array; -} - -function createRecordingCache(): RecordingCache { - const store = new Map(); - const sets: Array = []; - return { - sets, - cache: { - async get(key: string): Promise { - return (store.get(key) as T | undefined) ?? null; - }, - async set(key: string, value: T, ttlSeconds?: number): Promise { - store.set(key, value); - sets.push({key, value, ttlSeconds}); - }, - }, - }; -} - -function createRecordingAuditLogger(): {logger: IpInfoRequestAuditLogger; events: Array} { - const events: Array = []; - return { - events, - logger: { - async record(event: IpInfoRequestAuditEvent): Promise { - events.push(event); - }, - }, - }; -} - -function useLookupHandler(handler: () => Response | Promise): {count: () => number} { - let calls = 0; - server.use( - http.get('https://api.ipinfo.io/lookup/:ip', async () => { - calls += 1; - return await handler(); - }), - ); - return {count: () => calls}; -} - -function successPayload(ip: string, anonymous: Record = {}): Response { - return HttpResponse.json({ - ip, - geo: {country_code: 'US', country: 'United States'}, - as: {asn: 'AS64500', name: 'Test ISP'}, - anonymous, - }); -} - -describe('IpInfoService caching', () => { - it('negative-caches an HTTP error and serves the second lookup without a request', async () => { - const requests = useLookupHandler(() => new HttpResponse(null, {status: 500})); - const {cache, sets} = createRecordingCache(); - const service = createIpInfoService({apiKey: 'token', cache}); - - const first = await service.lookup('203.0.113.1'); - const second = await service.lookup('203.0.113.1'); - - expect(first.available).toBe(false); - expect(second.available).toBe(false); - expect(requests.count()).toBe(1); - expect(sets).toHaveLength(1); - expect(sets[0]?.ttlSeconds).toBe(300); - }); - - it('negative-caches a request failure for a short window', async () => { - useLookupHandler(async () => { - await delay(5000); - return successPayload('203.0.113.2'); - }); - const {cache, sets} = createRecordingCache(); - const service = createIpInfoService({apiKey: 'token', cache}); - - const result = await service.lookup('203.0.113.2'); - - expect(result.available).toBe(false); - expect(sets[0]?.ttlSeconds).toBe(60); - expect((sets[0]?.value as CachedIpInfoFailure)?.failureOutcome).toBe('request_failed'); - }); - - it('negative-caches a schema mismatch', async () => { - useLookupHandler(() => HttpResponse.json({})); - const {cache, sets} = createRecordingCache(); - const service = createIpInfoService({apiKey: 'token', cache}); - - const result = await service.lookup('203.0.113.3'); - - expect(result.available).toBe(false); - expect(sets[0]?.ttlSeconds).toBe(600); - expect((sets[0]?.value as CachedIpInfoFailure)?.failureOutcome).toBe('schema_mismatch'); - expect((sets[0]?.value as CachedIpInfoFailure)?.failureHttpStatus).toBe(200); - }); - - it('negative-caches a quota rejection for longer', async () => { - useLookupHandler(() => new HttpResponse(null, {status: 429})); - const {cache, sets} = createRecordingCache(); - const service = createIpInfoService({apiKey: 'token', cache}); - - await service.lookup('203.0.113.4'); - - expect(sets[0]?.ttlSeconds).toBe(900); - }); - - it('returns a cached failure as a clean unavailable result', async () => { - useLookupHandler(() => new HttpResponse(null, {status: 500})); - const {cache} = createRecordingCache(); - const service = createIpInfoService({apiKey: 'token', cache}); - - await service.lookup('203.0.113.6'); - const cached = await service.lookup('203.0.113.6'); - - expect(cached).not.toHaveProperty('cachedFailure'); - expect(cached).not.toHaveProperty('failureOutcome'); - expect(cached).not.toHaveProperty('failureHttpStatus'); - expect(cached).not.toHaveProperty('cachedAtMs'); - expect(cached.ip).toBe('203.0.113.6'); - expect(cached.note).toBe('IPInfo HTTP 500'); - }); - - it('writes a cached failure that older readers can still consume', async () => { - useLookupHandler(() => new HttpResponse(null, {status: 500})); - const {cache, sets} = createRecordingCache(); - const service = createIpInfoService({apiKey: 'token', cache}); - - await service.lookup('203.0.113.7'); - - const entry = sets[0]?.value as CachedIpInfoFailure; - expect(entry.cachedFailure).toBe(true); - expect(entry.failureOutcome).toBe('http_error'); - expect(entry.failureHttpStatus).toBe(500); - expect(typeof entry.cachedAtMs).toBe('number'); - const legacyView = {...entry, ip: '203.0.113.7'}; - expect(legacyView.available).toBe(false); - expect(legacyView.geo.countryCode).toBeNull(); - expect(legacyView.asn.number).toBeNull(); - expect(legacyView.mobile.name).toBeNull(); - expect(legacyView.anonymous.isAnonymous).toBe(false); - expect(legacyView.flags.isMobile).toBe(false); - }); - - it('keeps the existing success TTL selection', async () => { - useLookupHandler(() => successPayload('203.0.113.8')); - const plain = createRecordingCache(); - await createIpInfoService({apiKey: 'token', cache: plain.cache}).lookup('203.0.113.8'); - - useLookupHandler(() => successPayload('203.0.113.9', {is_vpn: true})); - const anonymous = createRecordingCache(); - await createIpInfoService({apiKey: 'token', cache: anonymous.cache}).lookup('203.0.113.9'); - - expect(plain.sets[0]?.ttlSeconds).toBe(14 * 24 * 60 * 60); - expect(anonymous.sets[0]?.ttlSeconds).toBe(7 * 24 * 60 * 60); - }); - - it('coalesces concurrent lookups across a failure', async () => { - const requests = useLookupHandler(() => new HttpResponse(null, {status: 500})); - const {cache, sets} = createRecordingCache(); - const service = createIpInfoService({apiKey: 'token', cache}); - - const [first, second] = await Promise.all([service.lookup('203.0.113.10'), service.lookup('203.0.113.10')]); - - expect(requests.count()).toBe(1); - expect(sets).toHaveLength(1); - expect(first.available).toBe(false); - expect(second.available).toBe(false); - }); - - it('coalesces concurrent lookups from different sources into one audited request', async () => { - const requests = useLookupHandler(() => successPayload('203.0.113.13')); - const {cache} = createRecordingCache(); - const {logger, events} = createRecordingAuditLogger(); - const service = createIpInfoService({apiKey: 'token', cache, auditLogger: logger}); - - const results = await Promise.all([ - service.lookup('203.0.113.13', {source: 'admin.ip_ban', reason: 'ban'}), - service.lookup('203.0.113.13', {source: 'test.b'}), - service.lookup('203.0.113.13', {source: 'test.c'}), - ]); - - expect(requests.count()).toBe(1); - expect(results.every((result) => result.available)).toBe(true); - expect(events).toHaveLength(1); - expect(events[0]?.source).toBe('admin.ip_ban'); - expect(events[0]?.outcome).toBe('http_success'); - expect(events[0]?.note).toBe('IPInfo: IP is not anonymous'); - }); -}); diff --git a/fluxer_api/src/api/ban/__tests__/PostgresIpInfoKv.test.ts b/fluxer_api/src/api/ban/__tests__/PostgresIpInfoKv.test.ts deleted file mode 100644 index 11a3c90f8..000000000 --- a/fluxer_api/src/api/ban/__tests__/PostgresIpInfoKv.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import type {IpInfoRequestAuditEvent} from '@pkgs/geoip/src/IpInfoService'; -import { - createPostgresIpInfoCache, - createPostgresIpInfoRequestAuditLogger, - IPINFO_CACHE_TTL_SECONDS, - IPINFO_REQUEST_AUDIT_TTL_SECONDS, -} from '@pkgs/geoip/src/PostgresIpInfoKv'; -import type {IPostgresClient} from '@pkgs/postgres/src/Client'; -import {describe, expect, it} from 'vitest'; - -function recordingClient(writes: Array>): IPostgresClient { - return { - async query(_text: string, values?: Array) { - writes.push(values ?? []); - return {rows: [], rowCount: 1}; - }, - kvTable() { - return 'kv'; - }, - } as never; -} - -function expectExpiresIn(values: Array | undefined, ttlSeconds: number): void { - const expiresAt = values?.[4]; - expect(expiresAt).toBeInstanceOf(Date); - const remainingSeconds = ((expiresAt as Date).getTime() - Date.now()) / 1000; - expect(remainingSeconds).toBeGreaterThan(ttlSeconds - 10); - expect(remainingSeconds).toBeLessThanOrEqual(ttlSeconds); -} - -const EVENT: IpInfoRequestAuditEvent = { - requestedAt: new Date('2026-09-21T12:00:00.000Z'), - ip: '192.0.2.1', - cacheKey: 'ip:192.0.2.1', - source: 'test', - reason: null, - outcome: 'http_success', - httpStatus: 200, - available: true, - note: 'none', - latencyMs: 12, - requestUrl: 'https://ipinfo.test/192.0.2.1', - responseIp: '192.0.2.1', - countryCode: 'SE', - asnNumber: 64500, - isAnonymous: false, - isTor: false, - isVpn: false, - isProxy: false, - isResidentialProxy: false, -}; - -describe('Postgres ipinfo KV expiry', () => { - it('expires request audit rows after 90 days', async () => { - const writes: Array> = []; - await createPostgresIpInfoRequestAuditLogger({client: recordingClient(writes)}).record(EVENT); - expect(writes).toHaveLength(1); - expect(writes[0]?.[0]).toBe('ipinfo_requests_by_hour'); - expectExpiresIn(writes[0], IPINFO_REQUEST_AUDIT_TTL_SECONDS); - }); - - it('falls back to the 14-day cache default', async () => { - const writes: Array> = []; - const cache = createPostgresIpInfoCache({client: recordingClient(writes)}); - await cache.set('fallback', {ok: true}); - await cache.set('zero', {ok: true}, 0); - await cache.set('short', {ok: true}, 60); - expect(writes.map((values) => values[0])).toEqual(['ipinfo_cache', 'ipinfo_cache', 'ipinfo_cache']); - expectExpiresIn(writes[0], IPINFO_CACHE_TTL_SECONDS); - expectExpiresIn(writes[1], IPINFO_CACHE_TTL_SECONDS); - expectExpiresIn(writes[2], 60); - }); -}); diff --git a/fluxer_api/src/api/ban/__tests__/TieredIpInfoCache.test.ts b/fluxer_api/src/api/ban/__tests__/TieredIpInfoCache.test.ts deleted file mode 100644 index d1d03ba74..000000000 --- a/fluxer_api/src/api/ban/__tests__/TieredIpInfoCache.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService'; -import {createTieredIpInfoCache} from '@pkgs/geoip/src/TieredIpInfoCache'; -import {describe, expect, it} from 'vitest'; - -interface RecordedSet { - key: string; - value: unknown; - ttlSeconds: number | undefined; -} - -interface RecordingCache { - cache: IpInfoCache; - store: Map; - sets: Array; -} - -function createRecordingCache(): RecordingCache { - const store = new Map(); - const sets: Array = []; - return { - store, - sets, - cache: { - async get(key: string): Promise { - return (store.get(key) as T | undefined) ?? null; - }, - async set(key: string, value: T, ttlSeconds?: number): Promise { - store.set(key, value); - sets.push({key, value, ttlSeconds}); - }, - }, - }; -} - -describe('TieredIpInfoCache', () => { - it('clamps the hot TTL to the requested TTL and passes the raw TTL to the cold tier', async () => { - const hot = createRecordingCache(); - const cold = createRecordingCache(); - const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache}); - - await tiered.set('a', {available: false}, 60); - - expect(hot.sets).toEqual([{key: 'a', value: {available: false}, ttlSeconds: 60}]); - expect(cold.sets).toEqual([{key: 'a', value: {available: false}, ttlSeconds: 60}]); - }); - - it('caps the hot TTL at the configured hot window', async () => { - const hot = createRecordingCache(); - const cold = createRecordingCache(); - const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache}); - - await tiered.set('a', {available: true}, 100000); - - expect(hot.sets[0]?.ttlSeconds).toBe(600); - expect(cold.sets[0]?.ttlSeconds).toBe(100000); - }); - - it('uses the hot window when no TTL is supplied', async () => { - const hot = createRecordingCache(); - const cold = createRecordingCache(); - const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache}); - - await tiered.set('a', {available: true}); - - expect(hot.sets[0]?.ttlSeconds).toBe(600); - expect(cold.sets[0]?.ttlSeconds).toBeUndefined(); - }); - - it('skips the cold write when skipColdWrite matches', async () => { - const hot = createRecordingCache(); - const cold = createRecordingCache(); - const tiered = createTieredIpInfoCache({ - hot: hot.cache, - cold: cold.cache, - skipColdWrite: (value) => (value as {available?: unknown}).available === false, - }); - - await tiered.set('a', {available: false}, 60); - await tiered.set('b', {available: true}, 60); - - expect(hot.sets.map((entry) => entry.key)).toEqual(['a', 'b']); - expect(cold.sets.map((entry) => entry.key)).toEqual(['b']); - }); - - it('promotes a cold hit into the hot tier', async () => { - const hot = createRecordingCache(); - const cold = createRecordingCache(); - cold.store.set('a', {available: true}); - const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache}); - - const hit = await tiered.get('a'); - - expect(hit).toEqual({available: true}); - expect(hot.sets).toEqual([{key: 'a', value: {available: true}, ttlSeconds: 600}]); - }); - - it('never promotes a cold hit that skipColdWrite matches', async () => { - const hot = createRecordingCache(); - const cold = createRecordingCache(); - cold.store.set('a', {available: false}); - const tiered = createTieredIpInfoCache({ - hot: hot.cache, - cold: cold.cache, - skipColdWrite: (value) => (value as {available?: unknown}).available === false, - }); - - const hit = await tiered.get('a'); - - expect(hit).toEqual({available: false}); - expect(hot.sets).toEqual([]); - }); - - it('never writes a zero TTL', async () => { - const hot = createRecordingCache(); - const cold = createRecordingCache(); - const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache}); - - await tiered.set('a', {available: false}, 60); - await tiered.set('b', {available: true}, 100000); - await tiered.set('c', {available: true}); - cold.store.set('d', {available: true}); - await tiered.get('d'); - - for (const entry of [...hot.sets, ...cold.sets]) { - expect(entry.ttlSeconds === undefined || entry.ttlSeconds > 0).toBe(true); - } - }); -}); diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index 25da7d75e..352df953d 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -164,9 +164,6 @@ export interface APIConfig { secure: boolean; }; }; - ipinfo: { - apiKey?: string; - }; blocklistFeeds: { enabled: boolean; }; diff --git a/fluxer_api/src/api/database/CassandraDefaultTtlParity.test.ts b/fluxer_api/src/api/database/CassandraDefaultTtlParity.test.ts index a262b21ef..967d6d82b 100644 --- a/fluxer_api/src/api/database/CassandraDefaultTtlParity.test.ts +++ b/fluxer_api/src/api/database/CassandraDefaultTtlParity.test.ts @@ -6,7 +6,6 @@ import {fileURLToPath} from 'node:url'; import {DEFAULT_TTL_TABLES} from '@app/api/database/PostgresKvDefaultTtlExpiry'; import * as DonationTables from '@app/api/donation/DonationTables'; import * as Tables from '@app/api/Tables'; -import {IPINFO_CACHE_TTL_SECONDS, IPINFO_REQUEST_AUDIT_TTL_SECONDS} from '@pkgs/geoip/src/PostgresIpInfoKv'; import {describe, expect, it} from 'vitest'; const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); @@ -32,8 +31,6 @@ const DSL_TABLES = [...Object.values(Tables), ...Object.values(DonationTables)]; const DSL_NAMES = new Set(DSL_TABLES.map((table) => table.name)); const NON_DSL_DEFAULTS: Record = { - ipinfo_cache: IPINFO_CACHE_TTL_SECONDS, - ipinfo_requests_by_hour: IPINFO_REQUEST_AUDIT_TTL_SECONDS, billing_webhook_events: null, forensic_identifier_by_key_day: null, forensic_identifier_by_request: null, diff --git a/fluxer_api/src/api/database/PostgresKvDefaultTtl.test.ts b/fluxer_api/src/api/database/PostgresKvDefaultTtl.test.ts index 5b96ece86..38b8e6b74 100644 --- a/fluxer_api/src/api/database/PostgresKvDefaultTtl.test.ts +++ b/fluxer_api/src/api/database/PostgresKvDefaultTtl.test.ts @@ -333,7 +333,6 @@ RETURNING updated_at::text`, await seed('attachment_upload_traces_by_key', 'at-29', '29 days'); await seed('oauth2_access_tokens', 'oa-8', '8 days'); await seed('donor_magic_link_tokens', 'dm-hour', '1 hour'); - await seed('ipinfo_requests_by_hour', 'ip-day', '1 day'); await seed('jobs_by_id', 'job', '100 days'); await seed('users', 'user', '100 days'); await seed('recent_mentions', 'rm-forever', '1 day', 'infinity'); @@ -346,7 +345,6 @@ RETURNING updated_at::text`, }); expect(await remaining()).toEqual([ {table_name: 'attachment_upload_traces_by_key', row_key: 'at-29'}, - {table_name: 'ipinfo_requests_by_hour', row_key: 'ip-day'}, {table_name: 'jobs_by_id', row_key: 'job'}, {table_name: 'recent_mentions', row_key: 'rm-day'}, {table_name: 'recent_mentions', row_key: 'rm-forever'}, diff --git a/fluxer_api/src/api/database/PostgresKvDefaultTtlExpiry.ts b/fluxer_api/src/api/database/PostgresKvDefaultTtlExpiry.ts index da186aa17..9f1b8cba4 100644 --- a/fluxer_api/src/api/database/PostgresKvDefaultTtlExpiry.ts +++ b/fluxer_api/src/api/database/PostgresKvDefaultTtlExpiry.ts @@ -7,7 +7,6 @@ import { } from '@app/api/database/PostgresKvQueryExecutor'; import * as DonationTables from '@app/api/donation/DonationTables'; import * as Tables from '@app/api/Tables'; -import {IPINFO_CACHE_TTL_SECONDS, IPINFO_REQUEST_AUDIT_TTL_SECONDS} from '@pkgs/geoip/src/PostgresIpInfoKv'; import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client'; import {ms} from 'itty-time'; @@ -23,8 +22,6 @@ export const DEFAULT_TTL_TABLES: ReadonlyArray<{name: string; defaultTtlSeconds: ? [] : [{name: table.name, defaultTtlSeconds: table.defaultTtlSeconds}], ), - {name: 'ipinfo_cache', defaultTtlSeconds: IPINFO_CACHE_TTL_SECONDS}, - {name: 'ipinfo_requests_by_hour', defaultTtlSeconds: IPINFO_REQUEST_AUDIT_TTL_SECONDS}, ]; export interface LegacyDefaultTtlExpiryResult { diff --git a/fluxer_api/src/api/guild/services/GuildMemberService.ts b/fluxer_api/src/api/guild/services/GuildMemberService.ts index b261c3f0d..bbe7c6ccb 100644 --- a/fluxer_api/src/api/guild/services/GuildMemberService.ts +++ b/fluxer_api/src/api/guild/services/GuildMemberService.ts @@ -24,7 +24,6 @@ import type {JoinSourceType} from '@fluxer/constants/src/GuildConstants'; import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type {GuildMemberUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas'; -import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService'; import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService'; export class GuildMemberService { @@ -47,11 +46,10 @@ export class GuildMemberService { rateLimitService: IRateLimitService, private readonly guildAuditLogService: GuildAuditLogService, limitConfigService: LimitConfigService, - ipInfoService: IpInfoService, ) { this.userRepository = userRepository; this.authService = new GuildMemberAuthService(gatewayService, userRepository); - this.validationService = new GuildMemberValidationService(guildRepository, userRepository, ipInfoService); + this.validationService = new GuildMemberValidationService(guildRepository, userRepository); this.auditService = new GuildMemberAuditService(guildAuditLogService); this.eventService = new GuildMemberEventService(gatewayService, userCacheService); this.searchIndexService = new GuildMemberSearchIndexService(); diff --git a/fluxer_api/src/api/guild/services/GuildModerationService.ts b/fluxer_api/src/api/guild/services/GuildModerationService.ts index 41ee52ffe..a810c8919 100644 --- a/fluxer_api/src/api/guild/services/GuildModerationService.ts +++ b/fluxer_api/src/api/guild/services/GuildModerationService.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {GuildID, UserID} from '@app/api/BrandedTypes'; -import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard'; import {isIpBanExempt} from '@app/api/ban/IpBanExemptions'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes'; @@ -27,7 +26,6 @@ import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownG import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; import {isSameIpDecisionMatch} from '@fluxer/ip_utils/src/IpAddress'; import type {GuildBanResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; -import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService'; import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService'; const SECONDS_PER_DAY = 86_400; @@ -42,7 +40,6 @@ export class GuildModerationService { private readonly userCacheService: UserCacheService, private readonly workerService: IWorkerService, private readonly guildAuditLogService: GuildAuditLogService, - private readonly ipInfoService: IpInfoService, ) { this.searchIndexService = new GuildMemberSearchIndexService(); } @@ -218,7 +215,7 @@ export class GuildModerationService { const userEmail = user?.email?.toLowerCase(); for (const ban of bans) { if (ban.userId === userId) throw new BannedFromGuildError(); - if (isSameIpDecisionMatch(userIp, ban.ipAddress) && (await this.shouldEnforceIpBan(userIp, ban.ipAddress))) { + if (isSameIpDecisionMatch(userIp, ban.ipAddress) && !isIpBanExempt(userIp)) { throw new IpBannedFromGuildError(); } } @@ -228,35 +225,6 @@ export class GuildModerationService { } } - private async shouldEnforceIpBan( - userIp: string | null | undefined, - bannedIp: string | null | undefined, - ): Promise { - if (isIpBanExempt(userIp)) { - return false; - } - if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) { - return true; - } - try { - const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, { - source: 'guild.ip_ban', - reason: 'join_cgnat_guard', - }); - const highRisk = cgnat || sharedAccess; - if (highRisk) { - Logger.warn( - {userIp, bannedIp}, - 'Skipping guild IP ban match because IPInfo indicates high shared-network blast-radius risk', - ); - } - return !highRisk; - } catch (error) { - Logger.warn({error, userIp, bannedIp}, 'IPInfo blast-radius guard failed while checking guild IP ban'); - return true; - } - } - private serializeBanForAudit(ban: GuildBan): Record { return { user_id: ban.userId.toString(), diff --git a/fluxer_api/src/api/guild/services/GuildService.ts b/fluxer_api/src/api/guild/services/GuildService.ts index f5a141ffd..bc078a3f2 100644 --- a/fluxer_api/src/api/guild/services/GuildService.ts +++ b/fluxer_api/src/api/guild/services/GuildService.ts @@ -58,7 +58,6 @@ import type { import type {GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import type {ICacheService} from '@pkgs/cache/src/ICacheService'; -import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService'; interface StoredAuditLogWebhookResponse extends Omit { type: number; @@ -113,7 +112,6 @@ export class GuildService { webhookRepository: IWebhookRepository, guildAuditLogService: GuildAuditLogService, limitConfigService: LimitConfigService, - ipInfoService: IpInfoService, ) { const { cache: cacheService, @@ -153,7 +151,6 @@ export class GuildService { rateLimitService, guildAuditLogService, limitConfigService, - ipInfoService, ); this.roles = new GuildRoleService( guildRepository, @@ -171,7 +168,6 @@ export class GuildService { userCacheService, workerService, guildAuditLogService, - ipInfoService, ); this.content = new GuildContentService( guildRepository, diff --git a/fluxer_api/src/api/guild/services/member/GuildMemberValidationService.ts b/fluxer_api/src/api/guild/services/member/GuildMemberValidationService.ts index 210c3881b..011d81bbc 100644 --- a/fluxer_api/src/api/guild/services/member/GuildMemberValidationService.ts +++ b/fluxer_api/src/api/guild/services/member/GuildMemberValidationService.ts @@ -2,10 +2,8 @@ import type {GuildID, RoleID, UserID} from '@app/api/BrandedTypes'; import {guildIdToRoleId} from '@app/api/BrandedTypes'; -import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard'; import {isIpBanExempt} from '@app/api/ban/IpBanExemptions'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; -import {Logger} from '@app/api/Logger'; import type {GuildMember} from '@app/api/models/GuildMember'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; @@ -17,7 +15,6 @@ import {IpBannedFromGuildError} from '@fluxer/errors/src/domains/guild/IpBannedF import {UnknownGuildRoleError} from '@fluxer/errors/src/domains/guild/UnknownGuildRoleError'; import {isSameIpDecisionMatch} from '@fluxer/ip_utils/src/IpAddress'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; -import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService'; function ensureNotEveryoneRole(roleId: RoleID, guildId: GuildID, path: string): void { if (roleId === guildIdToRoleId(guildId)) { @@ -29,7 +26,6 @@ export class GuildMemberValidationService { constructor( private readonly guildRepository: IGuildRepositoryAggregate, private readonly userRepository: IUserRepository, - private readonly ipInfoService: IpInfoService, ) {} async validateAndGetRoleIds(params: { @@ -102,38 +98,9 @@ export class GuildMemberValidationService { if (ban.userId === userId) { throw new BannedFromGuildError(); } - if (isSameIpDecisionMatch(userIp, ban.ipAddress) && (await this.shouldEnforceIpBan(userIp, ban.ipAddress))) { + if (isSameIpDecisionMatch(userIp, ban.ipAddress) && !isIpBanExempt(userIp)) { throw new IpBannedFromGuildError(); } } } - - private async shouldEnforceIpBan( - userIp: string | null | undefined, - bannedIp: string | null | undefined, - ): Promise { - if (isIpBanExempt(userIp)) { - return false; - } - if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) { - return true; - } - try { - const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, { - source: 'guild.member_ip_ban', - reason: 'join_cgnat_guard', - }); - const highRisk = cgnat || sharedAccess; - if (highRisk) { - Logger.warn( - {userIp, bannedIp}, - 'Skipping guild member IP ban match because IPInfo indicates high shared-network blast-radius risk', - ); - } - return !highRisk; - } catch (error) { - Logger.warn({error, userIp, bannedIp}, 'IPInfo CGNAT guard failed while checking guild member IP ban'); - return true; - } - } } diff --git a/fluxer_api/src/api/middleware/GuildStackServiceFactory.ts b/fluxer_api/src/api/middleware/GuildStackServiceFactory.ts index 79ef0f4fa..cce4aac4b 100644 --- a/fluxer_api/src/api/middleware/GuildStackServiceFactory.ts +++ b/fluxer_api/src/api/middleware/GuildStackServiceFactory.ts @@ -24,7 +24,6 @@ import type {ReadStateService} from '@app/api/read_state/ReadStateService'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService'; import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository'; -import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService'; import type {IVirusScanService} from '@pkgs/virus_scan/src/IVirusScanService'; interface GuildStackServiceFactoryDependencies { @@ -50,7 +49,6 @@ interface GuildStackServiceFactoryDependencies { voiceRoomStore: IVoiceRoomStore; liveKitService: ILiveKitService; voiceAvailabilityService: VoiceAvailabilityService | null; - ipInfoService: IpInfoService; } export interface GuildStackServices { @@ -106,7 +104,6 @@ class LazyGuildStackServices implements GuildStackServices { this.dependencies.webhookRepository, this.dependencies.guildAuditLogService, this.dependencies.limitConfigService, - this.dependencies.ipInfoService, ); return this.cachedGuildService; } diff --git a/fluxer_api/src/api/middleware/ServiceMiddleware.ts b/fluxer_api/src/api/middleware/ServiceMiddleware.ts index 2e0abc153..6b1e70fbf 100644 --- a/fluxer_api/src/api/middleware/ServiceMiddleware.ts +++ b/fluxer_api/src/api/middleware/ServiceMiddleware.ts @@ -6,7 +6,6 @@ import {AdminService} from '@app/api/admin/AdminService'; import {AuthRequestService} from '@app/api/auth/AuthRequestService'; import {DesktopHandoffService} from '@app/api/auth/services/DesktopHandoffService'; import {SsoService} from '@app/api/auth/services/SsoService'; -import {buildIpInfoCache, buildIpInfoRequestAuditLogger} from '@app/api/ban/IpInfoCacheFactory'; import type {IBlueskyOAuthService} from '@app/api/bluesky/IBlueskyOAuthService'; import {Config} from '@app/api/Config'; import {createApiContext} from '@app/api/CreateApiContext'; @@ -138,7 +137,6 @@ import {getRequestClientIp} from '@app/api/utils/RequestClientIp'; import {VoiceService} from '@app/api/voice/VoiceService'; import {WebhookRequestService} from '@app/api/webhook/WebhookRequestService'; import {WebhookService} from '@app/api/webhook/WebhookService'; -import {createIpInfoService, createUnavailableIpInfoService, type IpInfoService} from '@pkgs/geoip/src/IpInfoService'; import {createMiddleware} from 'hono/factory'; export {initializeServiceSingletons} from '@app/api/middleware/ServiceSingletons'; @@ -172,33 +170,6 @@ export function shutdownReportService(): void { } } -let _ipInfoService: IpInfoService | null = null; -let _injectedIpInfoService: IpInfoService | undefined; - -export function setInjectedIpInfoService(service: IpInfoService | undefined): void { - _injectedIpInfoService = service; -} - -export function getIpInfoService(): IpInfoService { - if (_injectedIpInfoService) { - return _injectedIpInfoService; - } - if (_ipInfoService) return _ipInfoService; - if (!Config.ipinfo.apiKey) { - _ipInfoService = createUnavailableIpInfoService('IPInfo API key not configured'); - return _ipInfoService; - } - const cache = buildIpInfoCache({ - hot: getCacheService(), - }); - _ipInfoService = createIpInfoService({ - apiKey: Config.ipinfo.apiKey, - cache, - auditLogger: buildIpInfoRequestAuditLogger(), - }); - return _ipInfoService; -} - let _liveKitWebhookService: LiveKitWebhookService | null = null; function getLiveKitWebhookService(): LiveKitWebhookService | null { @@ -349,7 +320,6 @@ class RequestServices implements RequestScopedServices { voiceRoomStore: this.voiceRooms, liveKitService: this.liveKit, voiceAvailabilityService: getVoiceAvailabilityService(), - ipInfoService: getIpInfoService(), }); return this.cachedGuildStack; } @@ -544,7 +514,6 @@ class RequestServices implements RequestScopedServices { getApplicationRepository(), this.stripeService.getStripe(), new JobLedgerRepository(), - getIpInfoService(), this.storeEntitlementService, ); return this.cachedAdminService; @@ -931,6 +900,5 @@ export const ServiceMiddleware = createMiddleware(async (ctx, next) => export function resetServiceMiddlewareForTesting(): void { shutdownReportService(); - _ipInfoService = null; _liveKitWebhookService = null; } diff --git a/fluxer_api/src/api/test/ApiTestHarness.ts b/fluxer_api/src/api/test/ApiTestHarness.ts index b2467de0e..0df020a8e 100644 --- a/fluxer_api/src/api/test/ApiTestHarness.ts +++ b/fluxer_api/src/api/test/ApiTestHarness.ts @@ -10,7 +10,6 @@ import { import {resetSharedListsForTests} from '@app/api/infrastructure/activity/SharedLists'; import {NullSearchProvider} from '@app/api/infrastructure/NullSearchProvider'; import {ipBanCache} from '@app/api/middleware/IpBanMiddleware'; -import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware'; import { setInjectedBlueskyOAuthService, setInjectedGatewayService, @@ -106,7 +105,6 @@ export async function createApiTestHarness(options: CreateApiTestHarnessOptions getInstanceConfigRepository().clearCacheForTesting(); kvProvider.reset(); mockBlueskyOAuthService.reset(); - setInjectedIpInfoService(undefined); setInjectedUnfurlerService(undefined); resetSharedListsForTests(); } @@ -134,7 +132,6 @@ export async function createApiTestHarness(options: CreateApiTestHarnessOptions setInjectedWorkerService(new NoopWorkerService()); setInjectedGatewayService(new NoopGatewayService()); setInjectedKVProvider(new MockKVProvider()); - setInjectedIpInfoService(undefined); setInjectedUnfurlerService(undefined); resetSharedListsForTests(); const fallbackStorageService = new MockStorageService(); diff --git a/fluxer_api/src/api/test/msw/handlers/IpInfoHandlers.ts b/fluxer_api/src/api/test/msw/handlers/IpInfoHandlers.ts deleted file mode 100644 index 48423f5d4..000000000 --- a/fluxer_api/src/api/test/msw/handlers/IpInfoHandlers.ts +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {HttpResponse, http} from 'msw'; - -export function createIpInfoLookupHandler() { - return http.get('https://api.ipinfo.io/lookup/:ip', ({params}) => { - const ip = typeof params.ip === 'string' ? params.ip : '198.51.100.1'; - return HttpResponse.json({ - ip, - geo: { - city: 'Ashburn', - region: 'Virginia', - region_code: 'VA', - country: 'United States', - country_code: 'US', - continent: 'North America', - continent_code: 'NA', - }, - as: { - asn: 'AS64500', - name: 'Test ISP', - domain: 'example.com', - type: 'isp', - }, - anonymous: {}, - }); - }); -} diff --git a/fluxer_api/src/api/test/msw/server.ts b/fluxer_api/src/api/test/msw/server.ts index 8fc13b296..2c20bc59f 100644 --- a/fluxer_api/src/api/test/msw/server.ts +++ b/fluxer_api/src/api/test/msw/server.ts @@ -1,6 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {createIpInfoLookupHandler} from '@app/api/test/msw/handlers/IpInfoHandlers'; import {createNcmecHandlers} from '@app/api/test/msw/handlers/NcmecHandlers'; import {createOnionooDetailsHandler} from '@app/api/test/msw/handlers/OnionooHandlers'; import {createOpenNsfwHandlers} from '@app/api/test/msw/handlers/OpenNsfwHandlers'; @@ -10,7 +9,6 @@ import {setupServer} from 'msw/node'; export const server = setupServer( ...createNcmecHandlers(), ...createOpenNsfwHandlers(), - createIpInfoLookupHandler(), createOnionooDetailsHandler(), createPwnedPasswordsRangeHandler(), ); diff --git a/fluxer_api/src/api/worker/WorkerDependencies.ts b/fluxer_api/src/api/worker/WorkerDependencies.ts index 7722be092..b4408d254 100644 --- a/fluxer_api/src/api/worker/WorkerDependencies.ts +++ b/fluxer_api/src/api/worker/WorkerDependencies.ts @@ -38,7 +38,6 @@ import type {InviteService} from '@app/api/invite/InviteService'; import {Logger} from '@app/api/Logger'; import type {LimitConfigService} from '@app/api/limits/LimitConfigService'; import {createGuildStackServices} from '@app/api/middleware/GuildStackServiceFactory'; -import {getIpInfoService} from '@app/api/middleware/ServiceMiddleware'; import { ensureVoiceResourcesInitialized, getGatewayService, @@ -237,7 +236,6 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS } const inviteRepository = getInviteRepository(); const webhookRepository = getWebhookRepository(); - const ipInfoService = getIpInfoService(); const contactChangeLogService = getContactChangeLogService(); const apiContext = createApiContext(); const {channelService, guildService, inviteService} = createGuildStackServices({ @@ -263,7 +261,6 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS voiceRoomStore, liveKitService, voiceAvailabilityService, - ipInfoService, }); const billingRepository = new BillingRepository(snowflakeService, kvClient); const storeEntitlementService = createStoreEntitlementService({ diff --git a/fluxer_api/src/api/worker/tasks/admin_bulk/AdminBulkServices.ts b/fluxer_api/src/api/worker/tasks/admin_bulk/AdminBulkServices.ts index 2ed287afe..3687119b1 100644 --- a/fluxer_api/src/api/worker/tasks/admin_bulk/AdminBulkServices.ts +++ b/fluxer_api/src/api/worker/tasks/admin_bulk/AdminBulkServices.ts @@ -5,7 +5,7 @@ import {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagem import {AdminGuildService} from '@app/api/admin/services/AdminGuildService'; import {AdminUserService} from '@app/api/admin/services/AdminUserService'; import {createApiContext} from '@app/api/CreateApiContext'; -import {getIpInfoService, getReportServiceInstance} from '@app/api/middleware/ServiceMiddleware'; +import {getReportServiceInstance} from '@app/api/middleware/ServiceMiddleware'; import { getDiscriminatorService, getEntityAssetService, @@ -28,7 +28,6 @@ export function createAdminBulkServices(deps: WorkerDependencies): AdminBulkServ apiContext, adminRepository: deps.adminRepository, auditService, - ipInfoService: getIpInfoService(), }); const userService = new AdminUserService({ apiContext, diff --git a/fluxer_api/src/api/worker/tests/AdminBulkGuildTasks.test.ts b/fluxer_api/src/api/worker/tests/AdminBulkGuildTasks.test.ts index b54c1fcbc..0423d62f1 100644 --- a/fluxer_api/src/api/worker/tests/AdminBulkGuildTasks.test.ts +++ b/fluxer_api/src/api/worker/tests/AdminBulkGuildTasks.test.ts @@ -10,7 +10,6 @@ import {DisabledLiveKitService} from '@app/api/infrastructure/DisabledLiveKitSer import {InMemoryVoiceRoomStore} from '@app/api/infrastructure/InMemoryVoiceRoomStore'; import {getMessages} from '@app/api/message/tests/MessageTestUtils'; import {createGuildStackServices} from '@app/api/middleware/GuildStackServiceFactory'; -import {getIpInfoService} from '@app/api/middleware/ServiceMiddleware'; import {getGatewayService, getSnowflakeService, getVoiceAvailabilityService} from '@app/api/middleware/ServiceRegistry'; import { getAdminRepository, @@ -97,7 +96,6 @@ function installWorkerDependencies(): void { voiceRoomStore: new InMemoryVoiceRoomStore(), liveKitService: new DisabledLiveKitService(), voiceAvailabilityService: getVoiceAvailabilityService(), - ipInfoService: getIpInfoService(), }); setWorkerDependenciesForTest({ adminRepository: getAdminRepository(), diff --git a/fluxer_docs/src/content/docs/admin-api/blocklists.mdx b/fluxer_docs/src/content/docs/admin-api/blocklists.mdx index 6899808e4..fe48ac7b3 100644 --- a/fluxer_docs/src/content/docs/admin-api/blocklists.mdx +++ b/fluxer_docs/src/content/docs/admin-api/blocklists.mdx @@ -27,7 +27,7 @@ Fluxer builds each permission name from `ban:`, the list name with each hyphen w | avatar-hash7 | Avatar hashes blocked from being set | | profile-substring3 8 | Substrings blocked from one named profile field | -1 Fluxer refuses an address with 400 `IP_BAN_DECLINED` when it is on the instance exemption list, or when IP lookup data shows that a single address is on a mobile carrier network, and records both refusals in the Admin audit log +1 Fluxer refuses an address with 400 `IP_BAN_DECLINED` when it is on the instance exemption list, and records the refusal in the Admin audit log 2 Stored lowercased, so a mixed-case value does not create a second row @@ -367,7 +367,7 @@ Every write is an upsert on the canonical value. An omitted optional field is wr A body field the selected blocklist does not accept is stripped and never produces that 400. A `url` Fluxer cannot canonicalise returns 400 `INVALID_FORM_BODY` naming `url` in the `errors` array. -Fluxer refuses to add an `ip` with 400 `IP_BAN_DECLINED` when the address is on the instance exemption list, and when a single address is classified as a high blast-radius mobile or carrier network. Fluxer runs that carrier network check only for a single address, so the check never refuses a CIDR range. When the IP lookup for the check fails, Fluxer writes the address. +Fluxer refuses to add an `ip` with 400 `IP_BAN_DECLINED` when the address is on the instance exemption list. The response has no body, so it does not report the canonical form that was stored. Read it back with [List blocklist entries](#list-blocklist-entries). @@ -375,7 +375,7 @@ The response has no body, so it does not report the canonical form that was stor Fluxer checks later requests against the written rows. For every list except `email`, other nodes see the rows after a short propagation delay. No Gateway Dispatch is emitted. -Fluxer records one [Admin audit entry](/admin-api/#admin-audit-entry-object) per written value, with that value in its metadata. It records an entry for a refused `ip` too, under the action `ban_ip_skipped_exempt` or `ban_ip_skipped_cgnat`, before it returns the 400. +Fluxer records one [Admin audit entry](/admin-api/#admin-audit-entry-object) per written value, with that value in its metadata. It records an entry for a refused `ip` too, under the action `ban_ip_skipped_exempt`, before it returns the 400. ### Rate limit diff --git a/fluxer_docs/src/content/docs/admin-api/index.mdx b/fluxer_docs/src/content/docs/admin-api/index.mdx index da9900096..85f891494 100644 --- a/fluxer_docs/src/content/docs/admin-api/index.mdx +++ b/fluxer_docs/src/content/docs/admin-api/index.mdx @@ -319,7 +319,6 @@ An entry with any other action has `access` set to `write`. | ban_email | An address was added to the email blocklist | | ban_file_sha | A file hash was added to the attachment blocklist | | ban_ip | An address was added to the IP blocklist | -| ban_ip_skipped_cgnat | An IP blocklist entry was declined because IP intelligence reports the address as a mobile carrier network | | ban_ip_skipped_exempt | An IP blocklist entry was declined by the instance exemption list | | ban_member | An account was banned from a guild | | ban_phrase | A phrase was added to the message phrase blocklist | diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index 269f5e74a..9678b97fd 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -988,10 +988,6 @@ Default `3310`. The scanner port. Integer. Compose forwards it from `.env`. Default `false`. Behaviour when the scanner is unreachable. With scanning on and this off, an unreachable scanner rejects every upload. Compose forwards it from `.env`. -#### `FLUXER_IPINFO_API_KEY` - -Default empty. The ipinfo key. Admin and guild IP bans use it to skip carrier-grade NAT and other shared addresses. Without it those checks treat every address as unknown. The previous name `FLUXER_RISK_IPINFO_API_KEY` is still read when this one is unset or empty. Compose forwards this name from `.env`, and not the previous one. - #### `FLUXER_BLOCKLIST_FEEDS_ENABLED` Defaults to the inverse of `FLUXER_SELF_HOSTED`. Off by default on a self-hosted instance. With feeds on, the worker downloads the URLhaus and PhishTank URL lists every six hours, and MalwareBazaar file hashes every twelve hours. Fluxer checks posted links against the URLs and uploads against the hashes. @@ -1686,7 +1682,7 @@ Every `FLUXER_CASSANDRA_` name. The stack runs Postgres. #### Older names of a forwarded setting -`FLUXER_NATS_CORE_URL`, `FLUXER_RISK_IPINFO_API_KEY`, `KLIPY_API_KEY` and `YOUTUBE_API_KEY`. A service reads each only when the name it replaces is unset or empty, and Compose forwards that name instead. +`FLUXER_NATS_CORE_URL`, `KLIPY_API_KEY` and `YOUTUBE_API_KEY`. A service reads each only when the name it replaces is unset or empty, and Compose forwards that name instead. #### Individual Stripe price names diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 3eee32044..3adaa9c97 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -215,9 +215,6 @@ function defaultConfig(): MasterConfig { }, blocklist_feeds: {}, breached_password_check: {}, - ipinfo: { - api_key: '', - }, push: { apns: { enabled: false, diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index a822b4eb2..768133cb5 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -240,9 +240,6 @@ export interface MasterConfig { breached_password_check: { enabled?: boolean; }; - ipinfo: { - api_key: string; - }; push: { apns: { enabled: boolean; diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index ff16cb191..31bb0ca79 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -715,14 +715,6 @@ describe('ConfigLoader', () => { expect(config.integrations.breached_password_check.enabled).toBe(false); }); - test('reads the IPinfo key from its current name before the previous one', async () => { - stubMinimalEnv({FLUXER_RISK_IPINFO_API_KEY: 'previous'}); - expect((await loadConfig()).integrations.ipinfo.api_key).toBe('previous'); - resetConfig(); - stubMinimalEnv({FLUXER_IPINFO_API_KEY: 'current', FLUXER_RISK_IPINFO_API_KEY: 'previous'}); - expect((await loadConfig()).integrations.ipinfo.api_key).toBe('current'); - }); - test('leaves Bluesky login off with no legal URLs by default', async () => { stubMinimalEnv(); diff --git a/packages/config/src/__tests__/EnvironmentOverrides.test.ts b/packages/config/src/__tests__/EnvironmentOverrides.test.ts index 6a5023ae1..ae4e4371c 100644 --- a/packages/config/src/__tests__/EnvironmentOverrides.test.ts +++ b/packages/config/src/__tests__/EnvironmentOverrides.test.ts @@ -86,12 +86,9 @@ describe('buildNamedFluxerEnvOverrides', () => { buildNamedFluxerEnvOverrides({ FLUXER_NATS_URL: '', FLUXER_NATS_CORE_URL: 'nats://alias', - FLUXER_IPINFO_API_KEY: ' ', - FLUXER_RISK_IPINFO_API_KEY: 'alias-key', }), ).toMatchObject({ services: {nats: {core_url: 'nats://alias'}}, - integrations: {ipinfo: {api_key: 'alias-key'}}, }); }); diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index 42512f14c..7c4feba4f 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -234,7 +234,6 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { path: ['integrations', 'breached_password_check', 'enabled'], parse: parseBoolean, }, - FLUXER_IPINFO_API_KEY: {path: ['integrations', 'ipinfo', 'api_key']}, FLUXER_PUSH_APNS_ENABLED: {path: ['integrations', 'push', 'apns', 'enabled'], parse: parseBoolean}, FLUXER_PUSH_APNS_TEAM_ID: {path: ['integrations', 'push', 'apns', 'team_id']}, FLUXER_PUSH_APNS_KEY_ID: {path: ['integrations', 'push', 'apns', 'key_id']}, @@ -396,7 +395,6 @@ export function setNestedValue(target: ConfigContainer, keys: Array = { FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: 'FLUXER_MEDIA_PROXY_ENDPOINT', FLUXER_NATS_URL: 'FLUXER_NATS_CORE_URL', - FLUXER_IPINFO_API_KEY: 'FLUXER_RISK_IPINFO_API_KEY', }; export const NAMED_FLUXER_ENV_NAMES = Object.keys(NAMED_FLUXER_ENV_OVERRIDES); diff --git a/tools/dev/cassandra_target_schema.json b/tools/dev/cassandra_target_schema.json index d28a19451..bf7e38837 100644 --- a/tools/dev/cassandra_target_schema.json +++ b/tools/dev/cassandra_target_schema.json @@ -5952,120 +5952,6 @@ "primary_key": "((token_), user_id)", "options": "default_time_to_live = 1800 AND gc_grace_seconds = 864000 AND compaction = {'class': 'TimeWindowCompactionStrategy', 'compaction_window_unit': 'HOURS', 'compaction_window_size': '1'}" }, - { - "name": "ipinfo_cache", - "columns": [ - { - "name": "cache_key", - "type": "text" - }, - { - "name": "payload", - "type": "text" - } - ], - "primary_key": "(cache_key)", - "options": "default_time_to_live = 1209600 AND gc_grace_seconds = 864000 AND compaction = {'class': 'TimeWindowCompactionStrategy', 'compaction_window_unit': 'HOURS', 'compaction_window_size': '12'}" - }, - { - "name": "ipinfo_requests_by_hour", - "columns": [ - { - "name": "bucket_date", - "type": "text" - }, - { - "name": "bucket_hour", - "type": "tinyint" - }, - { - "name": "requested_at", - "type": "timestamp" - }, - { - "name": "event_id", - "type": "uuid" - }, - { - "name": "source", - "type": "text" - }, - { - "name": "reason", - "type": "text" - }, - { - "name": "ip", - "type": "text" - }, - { - "name": "cache_key", - "type": "text" - }, - { - "name": "request_url", - "type": "text" - }, - { - "name": "http_status", - "type": "int" - }, - { - "name": "outcome", - "type": "text" - }, - { - "name": "available", - "type": "boolean" - }, - { - "name": "risk_note", - "type": "text" - }, - { - "name": "latency_ms", - "type": "int" - }, - { - "name": "response_ip", - "type": "text" - }, - { - "name": "country_code", - "type": "text" - }, - { - "name": "asn", - "type": "int" - }, - { - "name": "is_anonymous", - "type": "boolean" - }, - { - "name": "is_tor", - "type": "boolean" - }, - { - "name": "is_vpn", - "type": "boolean" - }, - { - "name": "is_proxy", - "type": "boolean" - }, - { - "name": "is_residential_proxy", - "type": "boolean" - }, - { - "name": "metadata_json", - "type": "text" - } - ], - "primary_key": "((bucket_date, bucket_hour), requested_at, event_id)", - "options": "CLUSTERING ORDER BY (requested_at DESC, event_id ASC) AND default_time_to_live = 7776000 AND gc_grace_seconds = 864000 AND compaction = {'class': 'TimeWindowCompactionStrategy', 'compaction_window_unit': 'DAYS', 'compaction_window_size': '3'}" - }, { "name": "jobs_active", "columns": [