From 4f154d212e98a73d94f6f851224030039bec4b89 Mon Sep 17 00:00:00 2001 From: Thomas Wilde Date: Tue, 16 Dec 2025 11:18:54 -0700 Subject: [PATCH] Add ASN-based resource rule matching - Add MaxMind ASN database integration - Implement ASN lookup and matching in resource rule verification - Add curated list of 100+ major ASNs (cloud, ISP, CDN, mobile carriers) - Add ASN dropdown selector in resource rules UI with search functionality - Support custom ASN input for unlisted ASNs - Add 'ALL ASNs' special case handling (AS0) - Cache ASN lookups with 5-minute TTL for performance - Update validation schemas to support ASN match type This allows administrators to create resource access rules based on Autonomous System Numbers, similar to existing country-based rules. Useful for restricting access by ISP, cloud provider, or mobile carrier. --- server/db/asns.ts | 321 ++++++++++++++++++ server/db/maxmindAsn.ts | 13 + server/lib/asn.ts | 29 ++ server/lib/config.ts | 4 + server/lib/readConfigFile.ts | 3 +- server/routers/badger/verifySession.ts | 63 +++- server/routers/resource/createResourceRule.ts | 2 +- server/routers/resource/updateResourceRule.ts | 2 +- .../resources/proxy/[niceId]/rules/page.tsx | 246 +++++++++++++- src/lib/pullEnv.ts | 3 +- src/lib/types/env.ts | 1 + 11 files changed, 678 insertions(+), 9 deletions(-) create mode 100644 server/db/asns.ts create mode 100644 server/db/maxmindAsn.ts create mode 100644 server/lib/asn.ts diff --git a/server/db/asns.ts b/server/db/asns.ts new file mode 100644 index 00000000..f78577f5 --- /dev/null +++ b/server/db/asns.ts @@ -0,0 +1,321 @@ +// Curated list of major ASNs (Cloud Providers, CDNs, ISPs, etc.) +// This is not exhaustive - there are 100,000+ ASNs globally +// Users can still enter any ASN manually in the input field +export const MAJOR_ASNS = [ + { + name: "ALL ASNs", + code: "ALL", + asn: 0 // Special value that will match all + }, + // Major Cloud Providers + { + name: "Google LLC", + code: "AS15169", + asn: 15169 + }, + { + name: "Amazon AWS", + code: "AS16509", + asn: 16509 + }, + { + name: "Amazon AWS (EC2)", + code: "AS14618", + asn: 14618 + }, + { + name: "Microsoft Azure", + code: "AS8075", + asn: 8075 + }, + { + name: "Microsoft Corporation", + code: "AS8068", + asn: 8068 + }, + { + name: "DigitalOcean", + code: "AS14061", + asn: 14061 + }, + { + name: "Linode", + code: "AS63949", + asn: 63949 + }, + { + name: "Hetzner Online", + code: "AS24940", + asn: 24940 + }, + { + name: "OVH SAS", + code: "AS16276", + asn: 16276 + }, + { + name: "Oracle Cloud", + code: "AS31898", + asn: 31898 + }, + { + name: "Alibaba Cloud", + code: "AS45102", + asn: 45102 + }, + { + name: "IBM Cloud", + code: "AS36351", + asn: 36351 + }, + + // CDNs + { + name: "Cloudflare", + code: "AS13335", + asn: 13335 + }, + { + name: "Fastly", + code: "AS54113", + asn: 54113 + }, + { + name: "Akamai Technologies", + code: "AS20940", + asn: 20940 + }, + { + name: "Akamai (Primary)", + code: "AS16625", + asn: 16625 + }, + + // Mobile Carriers - US + { + name: "T-Mobile USA", + code: "AS21928", + asn: 21928 + }, + { + name: "Verizon Wireless", + code: "AS6167", + asn: 6167 + }, + { + name: "AT&T Mobility", + code: "AS20057", + asn: 20057 + }, + { + name: "Sprint (T-Mobile)", + code: "AS1239", + asn: 1239 + }, + { + name: "US Cellular", + code: "AS6430", + asn: 6430 + }, + + // Mobile Carriers - Europe + { + name: "Vodafone UK", + code: "AS25135", + asn: 25135 + }, + { + name: "EE (UK)", + code: "AS12576", + asn: 12576 + }, + { + name: "Three UK", + code: "AS29194", + asn: 29194 + }, + { + name: "O2 UK", + code: "AS13285", + asn: 13285 + }, + { + name: "Telefonica Spain Mobile", + code: "AS12430", + asn: 12430 + }, + + // Mobile Carriers - Asia + { + name: "NTT DoCoMo (Japan)", + code: "AS9605", + asn: 9605 + }, + { + name: "SoftBank Mobile (Japan)", + code: "AS17676", + asn: 17676 + }, + { + name: "SK Telecom (Korea)", + code: "AS9318", + asn: 9318 + }, + { + name: "KT Corporation Mobile (Korea)", + code: "AS4766", + asn: 4766 + }, + { + name: "Airtel India", + code: "AS24560", + asn: 24560 + }, + { + name: "China Mobile", + code: "AS9808", + asn: 9808 + }, + + // Major US ISPs + { + name: "AT&T Services", + code: "AS7018", + asn: 7018 + }, + { + name: "Comcast Cable", + code: "AS7922", + asn: 7922 + }, + { + name: "Verizon", + code: "AS701", + asn: 701 + }, + { + name: "Cox Communications", + code: "AS22773", + asn: 22773 + }, + { + name: "Charter Communications", + code: "AS20115", + asn: 20115 + }, + { + name: "CenturyLink", + code: "AS209", + asn: 209 + }, + + // Major European ISPs + { + name: "Deutsche Telekom", + code: "AS3320", + asn: 3320 + }, + { + name: "Vodafone", + code: "AS1273", + asn: 1273 + }, + { + name: "British Telecom", + code: "AS2856", + asn: 2856 + }, + { + name: "Orange", + code: "AS3215", + asn: 3215 + }, + { + name: "Telefonica", + code: "AS12956", + asn: 12956 + }, + + // Major Asian ISPs + { + name: "China Telecom", + code: "AS4134", + asn: 4134 + }, + { + name: "China Unicom", + code: "AS4837", + asn: 4837 + }, + { + name: "NTT Communications", + code: "AS2914", + asn: 2914 + }, + { + name: "KDDI Corporation", + code: "AS2516", + asn: 2516 + }, + { + name: "Reliance Jio (India)", + code: "AS55836", + asn: 55836 + }, + + // VPN/Proxy Providers + { + name: "Private Internet Access", + code: "AS46562", + asn: 46562 + }, + { + name: "NordVPN", + code: "AS202425", + asn: 202425 + }, + { + name: "Mullvad VPN", + code: "AS213281", + asn: 213281 + }, + + // Social Media / Major Tech + { + name: "Facebook/Meta", + code: "AS32934", + asn: 32934 + }, + { + name: "Twitter/X", + code: "AS13414", + asn: 13414 + }, + { + name: "Apple", + code: "AS714", + asn: 714 + }, + { + name: "Netflix", + code: "AS2906", + asn: 2906 + }, + + // Academic/Research + { + name: "MIT", + code: "AS3", + asn: 3 + }, + { + name: "Stanford University", + code: "AS32", + asn: 32 + }, + { + name: "CERN", + code: "AS513", + asn: 513 + } +]; diff --git a/server/db/maxmindAsn.ts b/server/db/maxmindAsn.ts new file mode 100644 index 00000000..13951262 --- /dev/null +++ b/server/db/maxmindAsn.ts @@ -0,0 +1,13 @@ +import maxmind, { AsnResponse, Reader } from "maxmind"; +import config from "@server/lib/config"; + +let maxmindAsnLookup: Reader | null; +if (config.getRawConfig().server.maxmind_asn_path) { + maxmindAsnLookup = await maxmind.open( + config.getRawConfig().server.maxmind_asn_path! + ); +} else { + maxmindAsnLookup = null; +} + +export { maxmindAsnLookup }; diff --git a/server/lib/asn.ts b/server/lib/asn.ts new file mode 100644 index 00000000..18a39c46 --- /dev/null +++ b/server/lib/asn.ts @@ -0,0 +1,29 @@ +import logger from "@server/logger"; +import { maxmindAsnLookup } from "@server/db/maxmindAsn"; + +export async function getAsnForIp(ip: string): Promise { + try { + if (!maxmindAsnLookup) { + logger.debug( + "MaxMind ASN DB path not configured, cannot perform ASN lookup" + ); + return; + } + + const result = maxmindAsnLookup.get(ip); + + if (!result || !result.autonomous_system_number) { + return; + } + + logger.debug( + `ASN lookup successful for IP ${ip}: AS${result.autonomous_system_number}` + ); + + return result.autonomous_system_number; + } catch (error) { + logger.error("Error performing ASN lookup:", error); + } + + return; +} diff --git a/server/lib/config.ts b/server/lib/config.ts index 9874518e..405db2d1 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -99,6 +99,10 @@ export class Config { process.env.MAXMIND_DB_PATH = parsedConfig.server.maxmind_db_path; } + if (parsedConfig.server.maxmind_asn_path) { + process.env.MAXMIND_ASN_PATH = parsedConfig.server.maxmind_asn_path; + } + this.rawConfig = parsedConfig; } diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 365bcb13..da567820 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -133,7 +133,8 @@ export const configSchema = z .optional(), trust_proxy: z.int().gte(0).optional().default(1), secret: z.string().pipe(z.string().min(8)).optional(), - maxmind_db_path: z.string().optional() + maxmind_db_path: z.string().optional(), + maxmind_asn_path: z.string().optional() }) .optional() .default({ diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index d7fe9190..0e3a3489 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -29,6 +29,7 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { getCountryCodeForIp } from "@server/lib/geoip"; +import { getAsnForIp } from "@server/lib/asn"; import { getOrgTierData } from "#dynamic/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import { verifyPassword } from "@server/auth/password"; @@ -128,6 +129,10 @@ export async function verifyResourceSession( ? await getCountryCodeFromIp(clientIp) : undefined; + const ipAsn = clientIp + ? await getAsnFromIp(clientIp) + : undefined; + let cleanHost = host; // if the host ends with :port, strip it if (cleanHost.match(/:[0-9]{1,5}$/)) { @@ -216,7 +221,8 @@ export async function verifyResourceSession( resource.resourceId, clientIp, path, - ipCC + ipCC, + ipAsn ); if (action == "ACCEPT") { @@ -910,7 +916,8 @@ async function checkRules( resourceId: number, clientIp: string | undefined, path: string | undefined, - ipCC?: string + ipCC?: string, + ipAsn?: number ): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> { const ruleCacheKey = `rules:${resourceId}`; @@ -954,6 +961,12 @@ async function checkRules( (await isIpInGeoIP(ipCC, rule.value)) ) { return rule.action as any; + } else if ( + clientIp && + rule.match == "ASN" && + (await isIpInAsn(ipAsn, rule.value)) + ) { + return rule.action as any; } } @@ -1090,6 +1103,52 @@ async function isIpInGeoIP( return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase(); } +async function isIpInAsn( + ipAsn: number | undefined, + checkAsn: string +): Promise { + // Handle "ALL" special case + if (checkAsn === "ALL" || checkAsn === "AS0") { + return true; + } + + if (!ipAsn) { + return false; + } + + // Normalize the check ASN - remove "AS" prefix if present and convert to number + const normalizedCheckAsn = checkAsn.toUpperCase().replace(/^AS/, ""); + const checkAsnNumber = parseInt(normalizedCheckAsn, 10); + + if (isNaN(checkAsnNumber)) { + logger.warn(`Invalid ASN format in rule: ${checkAsn}`); + return false; + } + + const match = ipAsn === checkAsnNumber; + logger.debug( + `ASN check: IP ASN ${ipAsn} ${match ? "matches" : "does not match"} rule ASN ${checkAsnNumber}` + ); + + return match; +} + +async function getAsnFromIp(ip: string): Promise { + const asnCacheKey = `asn:${ip}`; + + let cachedAsn: number | undefined = cache.get(asnCacheKey); + + if (!cachedAsn) { + cachedAsn = await getAsnForIp(ip); // do it locally + // Cache for longer since IP ASN doesn't change frequently + if (cachedAsn) { + cache.set(asnCacheKey, cachedAsn, 300); // 5 minutes + } + } + + return cachedAsn; +} + async function getCountryCodeFromIp(ip: string): Promise { const geoIpCacheKey = `geoip:${ip}`; diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index 3f86665b..a516d14a 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -17,7 +17,7 @@ import { OpenAPITags, registry } from "@server/openApi"; const createResourceRuleSchema = z.strictObject({ action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]), + match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]), value: z.string().min(1), priority: z.int(), enabled: z.boolean().optional() diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index cae3f16e..b443bd1c 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -25,7 +25,7 @@ const updateResourceRuleParamsSchema = z.strictObject({ const updateResourceRuleSchema = z .strictObject({ action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(), - match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]).optional(), + match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]).optional(), value: z.string().min(1).optional(), priority: z.int(), enabled: z.boolean().optional() diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx index a58953e7..003b7f0e 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx @@ -75,6 +75,7 @@ import { Switch } from "@app/components/ui/switch"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { COUNTRIES } from "@server/db/countries"; +import { MAJOR_ASNS } from "@server/db/asns"; import { Command, CommandEmpty, @@ -117,11 +118,15 @@ export default function ResourceRules(props: { const [countrySelectValue, setCountrySelectValue] = useState(""); const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = useState(false); + const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = + useState(false); const router = useRouter(); const t = useTranslations(); const { env } = useEnvContext(); const isMaxmindAvailable = env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0; + const isMaxmindAsnAvailable = + env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0; const RuleAction = { ACCEPT: t("alwaysAllow"), @@ -133,7 +138,8 @@ export default function ResourceRules(props: { PATH: t("path"), IP: "IP", CIDR: t("ipAddressRange"), - COUNTRY: t("country") + COUNTRY: t("country"), + ASN: "ASN" } as const; const addRuleForm = useForm({ @@ -172,6 +178,30 @@ export default function ResourceRules(props: { }, []); async function addRule(data: z.infer) { + // Normalize ASN value + if (data.match === "ASN") { + const originalValue = data.value.toUpperCase(); + + // Handle special "ALL" case + if (originalValue === "ALL" || originalValue === "AS0") { + data.value = "ALL"; + } else { + // Remove AS prefix if present + const normalized = originalValue.replace(/^AS/, ""); + if (!/^\d+$/.test(normalized)) { + toast({ + variant: "destructive", + title: "Invalid ASN", + description: + "ASN must be a number, optionally prefixed with 'AS' (e.g., AS15169 or 15169), or 'ALL'" + }); + return; + } + // Add "AS" prefix for consistent storage + data.value = "AS" + normalized; + } + } + const isDuplicate = rules.some( (rule) => rule.action === data.action && @@ -280,6 +310,8 @@ export default function ResourceRules(props: { return t("rulesMatchUrl"); case "COUNTRY": return t("rulesMatchCountry"); + case "ASN": + return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; } } @@ -505,12 +537,12 @@ export default function ResourceRules(props: { ) @@ -592,6 +629,93 @@ export default function ResourceRules(props: { + ) : row.original.match === "ASN" ? ( + + + + + + + + + + No ASN found. Enter a custom ASN below. + + + {MAJOR_ASNS.map((asn) => ( + { + updateRule( + row.original.ruleId, + { value: asn.code } + ); + }} + > + + {asn.name} ({asn.code}) + + ))} + + + +
+ + asn.code === row.original.value + ) + ? row.original.value + : "" + } + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + updateRule( + row.original.ruleId, + { value: "AS" + value } + ); + } + } + }} + className="text-sm" + /> +
+
+
) : ( )} + {isMaxmindAsnAvailable && ( + + { + RuleMatch.ASN + } + + )} @@ -924,6 +1055,115 @@ export default function ResourceRules(props: { + ) : addRuleForm.watch( + "match" + ) === "ASN" ? ( + + + + + + + + + + No ASN found. Use the custom input below. + + + {MAJOR_ASNS.map( + ( + asn + ) => ( + { + field.onChange( + asn.code + ); + setOpenAddRuleAsnSelect( + false + ); + }} + > + + { + asn.name + }{" "} + ( + { + asn.code + } + ) + + ) + )} + + + +
+ { + if (e.key === "Enter") { + const value = e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + field.onChange("AS" + value); + setOpenAddRuleAsnSelect(false); + } + } + }} + className="text-sm" + /> +
+
+
) : ( )} diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index 4e7e2981..dbe47bd5 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -15,7 +15,8 @@ export function pullEnv(): Env { resourceAccessTokenHeadersToken: process.env .RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN as string, reoClientId: process.env.REO_CLIENT_ID as string, - maxmind_db_path: process.env.MAXMIND_DB_PATH as string + maxmind_db_path: process.env.MAXMIND_DB_PATH as string, + maxmind_asn_path: process.env.MAXMIND_ASN_PATH as string }, app: { environment: process.env.ENVIRONMENT as string, diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index d4b62d10..e40ac5d3 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -19,6 +19,7 @@ export type Env = { resourceAccessTokenHeadersToken: string; reoClientId?: string; maxmind_db_path?: string; + maxmind_asn_path?: string; }; email: { emailEnabled: boolean;