mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-17 10:26:39 +00:00
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:
committed by
Owen Schwartz
parent
1f4ebf1907
commit
8d2955475b
321
server/db/asns.ts
Normal file
321
server/db/asns.ts
Normal 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
13
server/db/maxmindAsn.ts
Normal 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
29
server/lib/asn.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user