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.
This commit is contained in:
Thomas Wilde
2025-12-16 11:18:54 -07:00
committed by Owen
parent 981d777a65
commit 4f154d212e
11 changed files with 678 additions and 9 deletions

321
server/db/asns.ts Normal file
View File

@@ -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
}
];

13
server/db/maxmindAsn.ts Normal file
View File

@@ -0,0 +1,13 @@
import maxmind, { AsnResponse, Reader } from "maxmind";
import config from "@server/lib/config";
let maxmindAsnLookup: Reader<AsnResponse> | null;
if (config.getRawConfig().server.maxmind_asn_path) {
maxmindAsnLookup = await maxmind.open<AsnResponse>(
config.getRawConfig().server.maxmind_asn_path!
);
} else {
maxmindAsnLookup = null;
}
export { maxmindAsnLookup };

29
server/lib/asn.ts Normal file
View File

@@ -0,0 +1,29 @@
import logger from "@server/logger";
import { maxmindAsnLookup } from "@server/db/maxmindAsn";
export async function getAsnForIp(ip: string): Promise<number | undefined> {
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;
}

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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<boolean> {
// 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<number | undefined> {
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<string | undefined> {
const geoIpCacheKey = `geoip:${ip}`;

View File

@@ -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()

View File

@@ -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()

View File

@@ -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<typeof addRuleSchema>) {
// 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: {
<Select
defaultValue={row.original.match}
onValueChange={(
value: "CIDR" | "IP" | "PATH" | "COUNTRY"
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN"
) =>
updateRule(row.original.ruleId, {
match: value,
value:
value === "COUNTRY" ? "US" : row.original.value
value === "COUNTRY" ? "US" : value === "ASN" ? "AS15169" : row.original.value
})
}
>
@@ -526,6 +558,11 @@ export default function ResourceRules(props: {
{RuleMatch.COUNTRY}
</SelectItem>
)}
{isMaxmindAsnAvailable && (
<SelectItem value="ASN">
{RuleMatch.ASN}
</SelectItem>
)}
</SelectContent>
</Select>
)
@@ -592,6 +629,93 @@ export default function ResourceRules(props: {
</Command>
</PopoverContent>
</Popover>
) : row.original.match === "ASN" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="min-w-[200px] justify-between"
>
{row.original.value
? (() => {
const found = MAJOR_ASNS.find(
(asn) =>
asn.code ===
row.original.value
);
return found
? `${found.name} (${row.original.value})`
: `Custom (${row.original.value})`;
})()
: "Select ASN"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-[200px] p-0">
<Command>
<CommandInput
placeholder="Search ASNs or enter custom..."
/>
<CommandList>
<CommandEmpty>
No ASN found. Enter a custom ASN below.
</CommandEmpty>
<CommandGroup>
{MAJOR_ASNS.map((asn) => (
<CommandItem
key={asn.code}
value={asn.name + " " + asn.code}
onSelect={() => {
updateRule(
row.original.ruleId,
{ value: asn.code }
);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
row.original.value ===
asn.code
? "opacity-100"
: "opacity-0"
}`}
/>
{asn.name} ({asn.code})
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<div className="border-t p-2">
<Input
placeholder="Enter custom ASN (e.g., AS15169)"
defaultValue={
!MAJOR_ASNS.find(
(asn) =>
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"
/>
</div>
</PopoverContent>
</Popover>
) : (
<Input
defaultValue={row.original.value}
@@ -802,6 +926,13 @@ export default function ResourceRules(props: {
}
</SelectItem>
)}
{isMaxmindAsnAvailable && (
<SelectItem value="ASN">
{
RuleMatch.ASN
}
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
@@ -924,6 +1055,115 @@ export default function ResourceRules(props: {
</Command>
</PopoverContent>
</Popover>
) : addRuleForm.watch(
"match"
) === "ASN" ? (
<Popover
open={
openAddRuleAsnSelect
}
onOpenChange={
setOpenAddRuleAsnSelect
}
>
<PopoverTrigger
asChild
>
<Button
variant="outline"
role="combobox"
aria-expanded={
openAddRuleAsnSelect
}
className="w-full justify-between"
>
{field.value
? MAJOR_ASNS.find(
(
asn
) =>
asn.code ===
field.value
)
?.name +
" (" +
field.value +
")" || field.value
: "Select ASN"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder="Search ASNs or enter custom..."
/>
<CommandList>
<CommandEmpty>
No ASN found. Use the custom input below.
</CommandEmpty>
<CommandGroup>
{MAJOR_ASNS.map(
(
asn
) => (
<CommandItem
key={
asn.code
}
value={
asn.name + " " + asn.code
}
onSelect={() => {
field.onChange(
asn.code
);
setOpenAddRuleAsnSelect(
false
);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
field.value ===
asn.code
? "opacity-100"
: "opacity-0"
}`}
/>
{
asn.name
}{" "}
(
{
asn.code
}
)
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
<div className="border-t p-2">
<Input
placeholder="Enter custom ASN (e.g., AS15169)"
onKeyDown={(e) => {
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"
/>
</div>
</PopoverContent>
</Popover>
) : (
<Input {...field} />
)}

View File

@@ -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,

View File

@@ -19,6 +19,7 @@ export type Env = {
resourceAccessTokenHeadersToken: string;
reoClientId?: string;
maxmind_db_path?: string;
maxmind_asn_path?: string;
};
email: {
emailEnabled: boolean;