mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-14 08:56: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
@@ -74,6 +74,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,
|
||||
@@ -116,11 +117,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"),
|
||||
@@ -132,7 +137,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({
|
||||
@@ -171,6 +177,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 &&
|
||||
@@ -279,6 +309,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)";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,12 +536,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
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -525,6 +557,11 @@ export default function ResourceRules(props: {
|
||||
{RuleMatch.COUNTRY}
|
||||
</SelectItem>
|
||||
)}
|
||||
{isMaxmindAsnAvailable && (
|
||||
<SelectItem value="ASN">
|
||||
{RuleMatch.ASN}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
@@ -591,6 +628,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}
|
||||
@@ -801,6 +925,13 @@ export default function ResourceRules(props: {
|
||||
}
|
||||
</SelectItem>
|
||||
)}
|
||||
{isMaxmindAsnAvailable && (
|
||||
<SelectItem value="ASN">
|
||||
{
|
||||
RuleMatch.ASN
|
||||
}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
@@ -923,6 +1054,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} />
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ export type Env = {
|
||||
resourceAccessTokenHeadersToken: string;
|
||||
reoClientId?: string;
|
||||
maxmind_db_path?: string;
|
||||
maxmind_asn_path?: string;
|
||||
};
|
||||
email: {
|
||||
emailEnabled: boolean;
|
||||
|
||||
Reference in New Issue
Block a user