mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-31 15:06:42 +00:00
Add region-based resource rule
This commit is contained in:
@@ -1734,6 +1734,34 @@
|
|||||||
"exitNode": "Exit-Node",
|
"exitNode": "Exit-Node",
|
||||||
"country": "Land",
|
"country": "Land",
|
||||||
"rulesMatchCountry": "Derzeit basierend auf der Quell-IP",
|
"rulesMatchCountry": "Derzeit basierend auf der Quell-IP",
|
||||||
|
"region": "Region",
|
||||||
|
"regionAfrica": "Afrika",
|
||||||
|
"regionNorthernAfrica": "Nordafrika",
|
||||||
|
"regionEasternAfrica": "Ostafrika",
|
||||||
|
"regionMiddleAfrica": "Zentralafrika",
|
||||||
|
"regionSouthernAfrica": "Südliches Afrika",
|
||||||
|
"regionWesternAfrica": "Westafrika",
|
||||||
|
"regionAmericas": "Amerika",
|
||||||
|
"regionCaribbean": "Karibik",
|
||||||
|
"regionCentralAmerica": "Mittelamerika",
|
||||||
|
"regionSouthAmerica": "Südamerika",
|
||||||
|
"regionNorthernAmerica": "Nordamerika",
|
||||||
|
"regionAsia": "Asien",
|
||||||
|
"regionCentralAsia": "Zentralasien",
|
||||||
|
"regionEasternAsia": "Ostasien",
|
||||||
|
"regionSouthEasternAsia": "Südostasien",
|
||||||
|
"regionSouthernAsia": "Südasien",
|
||||||
|
"regionWesternAsia": "Westasien",
|
||||||
|
"regionEurope": "Europa",
|
||||||
|
"regionEasternEurope": "Osteuropa",
|
||||||
|
"regionNorthernEurope": "Nordeuropa",
|
||||||
|
"regionSouthernEurope": "Südeuropa",
|
||||||
|
"regionWesternEurope": "Westeuropa",
|
||||||
|
"regionOceania": "Ozeanien",
|
||||||
|
"regionAustraliaAndNewZealand": "Australien und Neuseeland",
|
||||||
|
"regionMelanesia": "Melanesien",
|
||||||
|
"regionMicronesia": "Mikronesien",
|
||||||
|
"regionPolynesia": "Polynesien",
|
||||||
"managedSelfHosted": {
|
"managedSelfHosted": {
|
||||||
"title": "Verwaltetes Selbsthosted",
|
"title": "Verwaltetes Selbsthosted",
|
||||||
"description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen",
|
"description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen",
|
||||||
|
|||||||
@@ -1734,6 +1734,40 @@
|
|||||||
"exitNode": "Exit Node",
|
"exitNode": "Exit Node",
|
||||||
"country": "Country",
|
"country": "Country",
|
||||||
"rulesMatchCountry": "Currently based on source IP",
|
"rulesMatchCountry": "Currently based on source IP",
|
||||||
|
"region": "Region",
|
||||||
|
"selectRegion": "Select region",
|
||||||
|
"searchRegions": "Search regions...",
|
||||||
|
"noRegionFound": "No region found.",
|
||||||
|
"rulesMatchRegion": "Select a regional grouping of countries",
|
||||||
|
"rulesErrorInvalidRegion": "Invalid region",
|
||||||
|
"rulesErrorInvalidRegionDescription": "Please select a valid region.",
|
||||||
|
"regionAfrica": "Africa",
|
||||||
|
"regionNorthernAfrica": "Northern Africa",
|
||||||
|
"regionEasternAfrica": "Eastern Africa",
|
||||||
|
"regionMiddleAfrica": "Middle Africa",
|
||||||
|
"regionSouthernAfrica": "Southern Africa",
|
||||||
|
"regionWesternAfrica": "Western Africa",
|
||||||
|
"regionAmericas": "Americas",
|
||||||
|
"regionCaribbean": "Caribbean",
|
||||||
|
"regionCentralAmerica": "Central America",
|
||||||
|
"regionSouthAmerica": "South America",
|
||||||
|
"regionNorthernAmerica": "Northern America",
|
||||||
|
"regionAsia": "Asia",
|
||||||
|
"regionCentralAsia": "Central Asia",
|
||||||
|
"regionEasternAsia": "Eastern Asia",
|
||||||
|
"regionSouthEasternAsia": "South-Eastern Asia",
|
||||||
|
"regionSouthernAsia": "Southern Asia",
|
||||||
|
"regionWesternAsia": "Western Asia",
|
||||||
|
"regionEurope": "Europe",
|
||||||
|
"regionEasternEurope": "Eastern Europe",
|
||||||
|
"regionNorthernEurope": "Northern Europe",
|
||||||
|
"regionSouthernEurope": "Southern Europe",
|
||||||
|
"regionWesternEurope": "Western Europe",
|
||||||
|
"regionOceania": "Oceania",
|
||||||
|
"regionAustraliaAndNewZealand": "Australia and New Zealand",
|
||||||
|
"regionMelanesia": "Melanesia",
|
||||||
|
"regionMicronesia": "Micronesia",
|
||||||
|
"regionPolynesia": "Polynesia",
|
||||||
"managedSelfHosted": {
|
"managedSelfHosted": {
|
||||||
"title": "Managed Self-Hosted",
|
"title": "Managed Self-Hosted",
|
||||||
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
|
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
|
||||||
|
|||||||
@@ -1734,6 +1734,34 @@
|
|||||||
"exitNode": "Узел выхода",
|
"exitNode": "Узел выхода",
|
||||||
"country": "Страна",
|
"country": "Страна",
|
||||||
"rulesMatchCountry": "В настоящее время основано на исходном IP",
|
"rulesMatchCountry": "В настоящее время основано на исходном IP",
|
||||||
|
"region": "Регион",
|
||||||
|
"regionAfrica": "Африка",
|
||||||
|
"regionNorthernAfrica": "Северная Африка",
|
||||||
|
"regionEasternAfrica": "Восточная Африка",
|
||||||
|
"regionMiddleAfrica": "Центральная Африка",
|
||||||
|
"regionSouthernAfrica": "Южная Африка",
|
||||||
|
"regionWesternAfrica": "Западная Африка",
|
||||||
|
"regionAmericas": "Америка",
|
||||||
|
"regionCaribbean": "Карибы",
|
||||||
|
"regionCentralAmerica": "Центральная Америка",
|
||||||
|
"regionSouthAmerica": "Южная Америка",
|
||||||
|
"regionNorthernAmerica": "Северная Америка",
|
||||||
|
"regionAsia": "Азия",
|
||||||
|
"regionCentralAsia": "Центральная Азия",
|
||||||
|
"regionEasternAsia": "Восточная Азия",
|
||||||
|
"regionSouthEasternAsia": "Юго-Восточная Азия",
|
||||||
|
"regionSouthernAsia": "Южная Азия",
|
||||||
|
"regionWesternAsia": "Западная Азия",
|
||||||
|
"regionEurope": "Европа",
|
||||||
|
"regionEasternEurope": "Восточная Европа",
|
||||||
|
"regionNorthernEurope": "Северная Европа",
|
||||||
|
"regionSouthernEurope": "Южная Европа",
|
||||||
|
"regionWesternEurope": "Западная Европа",
|
||||||
|
"regionOceania": "Океания",
|
||||||
|
"regionAustraliaAndNewZealand": "Австралия и Новая Зеландия",
|
||||||
|
"regionMelanesia": "Меланезия",
|
||||||
|
"regionMicronesia": "Микронезия",
|
||||||
|
"regionPolynesia": "Полинезия",
|
||||||
"managedSelfHosted": {
|
"managedSelfHosted": {
|
||||||
"title": "Управляемый с самовывоза",
|
"title": "Управляемый с самовывоза",
|
||||||
"description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками",
|
"description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками",
|
||||||
|
|||||||
196
server/db/regions.ts
Normal file
196
server/db/regions.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// Regions of the World
|
||||||
|
// as of 2025-10-25
|
||||||
|
//
|
||||||
|
// Adapted according to the United Nations Geoscheme
|
||||||
|
// see https://www.unicode.org/cldr/charts/48/supplemental/territory_containment_un_m_49.html
|
||||||
|
// see https://unstats.un.org/unsd/methodology/m49
|
||||||
|
|
||||||
|
export const REGIONS = [
|
||||||
|
{
|
||||||
|
name: "regionAfrica",
|
||||||
|
id: "002",
|
||||||
|
includes: [
|
||||||
|
{
|
||||||
|
name: "regionNorthernAfrica",
|
||||||
|
id: "015",
|
||||||
|
countries: ["DZ", "EG", "LY", "MA", "SD", "TN", "EH"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionEasternAfrica",
|
||||||
|
id: "014",
|
||||||
|
countries: ["IO", "BI", "KM", "DJ", "ER", "ET", "TF", "KE", "MG", "MW", "MU", "YT", "MZ", "RE", "RW", "SC", "SO", "SS", "UG", "ZM", "ZW"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionMiddleAfrica",
|
||||||
|
id: "017",
|
||||||
|
countries: ["AO", "CM", "CF", "TD", "CG", "CD", "GQ", "GA", "ST"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionSouthernAfrica",
|
||||||
|
id: "018",
|
||||||
|
countries: ["BW", "SZ", "LS", "NA", "ZA"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionWesternAfrica",
|
||||||
|
id: "011",
|
||||||
|
countries: ["BJ", "BF", "CV", "CI", "GM", "GH", "GN", "GW", "LR", "ML", "MR", "NE", "NG", "SH", "SN", "SL", "TG"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionAmericas",
|
||||||
|
id: "019",
|
||||||
|
includes: [
|
||||||
|
{
|
||||||
|
name: "regionCaribbean",
|
||||||
|
id: "029",
|
||||||
|
countries: ["AI", "AG", "AW", "BS", "BB", "BQ", "VG", "KY", "CU", "CW", "DM", "DO", "GD", "GP", "HT", "JM", "MQ", "MS", "PR", "BL", "KN", "LC", "MF", "VC", "SX", "TT", "TC", "VI"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionCentralAmerica",
|
||||||
|
id: "013",
|
||||||
|
countries: ["BZ", "CR", "SV", "GT", "HN", "MX", "NI", "PA"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionSouthAmerica",
|
||||||
|
id: "005",
|
||||||
|
countries: ["AR", "BO", "BV", "BR", "CL", "CO", "EC", "FK", "GF", "GY", "PY", "PE", "GS", "SR", "UY", "VE"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionNorthernAmerica",
|
||||||
|
id: "021",
|
||||||
|
countries: ["BM", "CA", "GL", "PM", "US"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionAsia",
|
||||||
|
id: "142",
|
||||||
|
includes: [
|
||||||
|
{
|
||||||
|
name: "regionCentralAsia",
|
||||||
|
id: "143",
|
||||||
|
countries: ["KZ", "KG", "TJ", "TM", "UZ"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionEasternAsia",
|
||||||
|
id: "030",
|
||||||
|
countries: ["CN", "HK", "MO", "KP", "JP", "MN", "KR"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionSouthEasternAsia",
|
||||||
|
id: "035",
|
||||||
|
countries: ["BN", "KH", "ID", "LA", "MY", "MM", "PH", "SG", "TH", "TL", "VN"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionSouthernAsia",
|
||||||
|
id: "034",
|
||||||
|
countries: ["AF", "BD", "BT", "IN", "IR", "MV", "NP", "PK", "LK"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionWesternAsia",
|
||||||
|
id: "145",
|
||||||
|
countries: ["AM", "AZ", "BH", "CY", "GE", "IQ", "IL", "JO", "KW", "LB", "OM", "QA", "SA", "PS", "SY", "TR", "AE", "YE"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionEurope",
|
||||||
|
id: "150",
|
||||||
|
includes: [
|
||||||
|
{
|
||||||
|
name: "regionEasternEurope",
|
||||||
|
id: "151",
|
||||||
|
countries: ["BY", "BG", "CZ", "HU", "PL", "MD", "RO", "RU", "SK", "UA"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionNorthernEurope",
|
||||||
|
id: "154",
|
||||||
|
countries: ["AX", "DK", "EE", "FO", "FI", "GG", "IS", "IE", "IM", "JE", "LV", "LT", "NO", "SJ", "SE", "GB"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionSouthernEurope",
|
||||||
|
id: "039",
|
||||||
|
countries: ["AL", "AD", "BA", "HR", "GI", "GR", "VA", "IT", "MT", "ME", "MK", "PT", "SM", "RS", "SI", "ES"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionWesternEurope",
|
||||||
|
id: "155",
|
||||||
|
countries: ["AT", "BE", "FR", "DE", "LI", "LU", "MC", "NL", "CH"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionOceania",
|
||||||
|
id: "009",
|
||||||
|
includes: [
|
||||||
|
{
|
||||||
|
name: "regionAustraliaAndNewZealand",
|
||||||
|
id: "053",
|
||||||
|
countries: ["AU", "CX", "CC", "HM", "NZ", "NF"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionMelanesia",
|
||||||
|
id: "054",
|
||||||
|
countries: ["FJ", "NC", "PG", "SB", "VU"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionMicronesia",
|
||||||
|
id: "057",
|
||||||
|
countries: ["GU", "KI", "MH", "FM", "NR", "MP", "PW", "UM"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regionPolynesia",
|
||||||
|
id: "061",
|
||||||
|
countries: ["AS", "CK", "PF", "NU", "PN", "WS", "TK", "TO", "TV", "WF"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
type Subregion = {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
countries: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Region = {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
includes: Subregion[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRegionNameById(regionId: string): string | undefined {
|
||||||
|
// Check top-level regions
|
||||||
|
const region = REGIONS.find((r) => r.id === regionId);
|
||||||
|
if (region) {
|
||||||
|
return region.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check subregions
|
||||||
|
for (const region of REGIONS) {
|
||||||
|
for (const subregion of region.includes) {
|
||||||
|
if (subregion.id === regionId) {
|
||||||
|
return subregion.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidRegionId(regionId: string): boolean {
|
||||||
|
// Check top-level regions
|
||||||
|
if (REGIONS.find((r) => r.id === regionId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check subregions
|
||||||
|
for (const region of REGIONS) {
|
||||||
|
if (region.includes.find((s) => s.id === regionId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -1,4 +1,37 @@
|
|||||||
import { assertEquals } from "@test/assert";
|
import { assertEquals } from "@test/assert";
|
||||||
|
import { REGIONS } from "@server/db/regions";
|
||||||
|
|
||||||
|
function isIpInRegion(
|
||||||
|
ipCountryCode: string | undefined,
|
||||||
|
checkRegionCode: string
|
||||||
|
): boolean {
|
||||||
|
if (!ipCountryCode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upperCode = ipCountryCode.toUpperCase();
|
||||||
|
|
||||||
|
for (const region of REGIONS) {
|
||||||
|
// Check if it's a top-level region (continent)
|
||||||
|
if (region.id === checkRegionCode) {
|
||||||
|
for (const subregion of region.includes) {
|
||||||
|
if (subregion.countries.includes(upperCode)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check subregions
|
||||||
|
for (const subregion of region.includes) {
|
||||||
|
if (subregion.id === checkRegionCode) {
|
||||||
|
return subregion.countries.includes(upperCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function isPathAllowed(pattern: string, path: string): boolean {
|
function isPathAllowed(pattern: string, path: string): boolean {
|
||||||
// Normalize and split paths into segments
|
// Normalize and split paths into segments
|
||||||
@@ -272,12 +305,71 @@ function runTests() {
|
|||||||
"Root path should not match non-root path"
|
"Root path should not match non-root path"
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("All tests passed!");
|
console.log("All path matching tests passed!");
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRegionTests() {
|
||||||
|
console.log("\nRunning isIpInRegion tests...");
|
||||||
|
|
||||||
|
// Test undefined country code
|
||||||
|
assertEquals(
|
||||||
|
isIpInRegion(undefined, "150"),
|
||||||
|
false,
|
||||||
|
"Undefined country code should return false"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test subregion matching (Western Europe)
|
||||||
|
assertEquals(
|
||||||
|
isIpInRegion("DE", "155"),
|
||||||
|
true,
|
||||||
|
"Country should match its subregion"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
isIpInRegion("GB", "155"),
|
||||||
|
false,
|
||||||
|
"Country should NOT match wrong subregion"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test continent matching (Europe)
|
||||||
|
assertEquals(
|
||||||
|
isIpInRegion("DE", "150"),
|
||||||
|
true,
|
||||||
|
"Country should match its continent"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
isIpInRegion("GB", "150"),
|
||||||
|
true,
|
||||||
|
"Different European country should match Europe"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
isIpInRegion("US", "150"),
|
||||||
|
false,
|
||||||
|
"Non-European country should NOT match Europe"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case insensitivity
|
||||||
|
assertEquals(
|
||||||
|
isIpInRegion("de", "155"),
|
||||||
|
true,
|
||||||
|
"Lowercase country code should work"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test invalid region code
|
||||||
|
assertEquals(
|
||||||
|
isIpInRegion("DE", "999"),
|
||||||
|
false,
|
||||||
|
"Invalid region code should return false"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("All region tests passed!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run all tests
|
// Run all tests
|
||||||
try {
|
try {
|
||||||
runTests();
|
runTests();
|
||||||
|
runRegionTests();
|
||||||
|
console.log("\n✅ All tests passed!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Test failed:", error);
|
console.error("❌ Test failed:", error);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
} from "#dynamic/lib/checkOrgAccessPolicy";
|
} from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { logRequestAudit } from "./logRequestAudit";
|
import { logRequestAudit } from "./logRequestAudit";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "@server/lib/cache";
|
||||||
|
import { REGIONS } from "@server/db/regions";
|
||||||
|
|
||||||
const verifyResourceSessionSchema = z.object({
|
const verifyResourceSessionSchema = z.object({
|
||||||
sessions: z.record(z.string(), z.string()).optional(),
|
sessions: z.record(z.string(), z.string()).optional(),
|
||||||
@@ -967,6 +968,12 @@ async function checkRules(
|
|||||||
(await isIpInAsn(ipAsn, rule.value))
|
(await isIpInAsn(ipAsn, rule.value))
|
||||||
) {
|
) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
|
} else if (
|
||||||
|
clientIp &&
|
||||||
|
rule.match == "REGION" &&
|
||||||
|
(await isIpInRegion(ipCC, rule.value))
|
||||||
|
) {
|
||||||
|
return rule.action as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,6 +1140,45 @@ async function isIpInAsn(
|
|||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function isIpInRegion(
|
||||||
|
ipCountryCode: string | undefined,
|
||||||
|
checkRegionCode: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!ipCountryCode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upperCode = ipCountryCode.toUpperCase();
|
||||||
|
|
||||||
|
for (const region of REGIONS) {
|
||||||
|
// Check if it's a top-level region (continent)
|
||||||
|
if (region.id === checkRegionCode) {
|
||||||
|
for (const subregion of region.includes) {
|
||||||
|
if (subregion.countries.includes(upperCode)) {
|
||||||
|
logger.debug(`Country ${upperCode} is in region ${region.id} (${region.name})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.debug(`Country ${upperCode} is not in region ${region.id} (${region.name})`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check subregions
|
||||||
|
for (const subregion of region.includes) {
|
||||||
|
if (subregion.id === checkRegionCode) {
|
||||||
|
if (subregion.countries.includes(upperCode)) {
|
||||||
|
logger.debug(`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
logger.debug(`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function getAsnFromIp(ip: string): Promise<number | undefined> {
|
async function getAsnFromIp(ip: string): Promise<number | undefined> {
|
||||||
const asnCacheKey = `asn:${ip}`;
|
const asnCacheKey = `asn:${ip}`;
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ import {
|
|||||||
isValidUrlGlobPattern
|
isValidUrlGlobPattern
|
||||||
} from "@server/lib/validators";
|
} from "@server/lib/validators";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
|
|
||||||
const createResourceRuleSchema = z.strictObject({
|
const createResourceRuleSchema = z.strictObject({
|
||||||
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||||
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]),
|
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]),
|
||||||
value: z.string().min(1),
|
value: z.string().min(1),
|
||||||
priority: z.int(),
|
priority: z.int(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
@@ -126,6 +127,15 @@ export async function createResourceRule(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (match === "REGION") {
|
||||||
|
if (!isValidRegionId(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid region ID provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the new resource rule
|
// Create the new resource rule
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
isValidUrlGlobPattern
|
isValidUrlGlobPattern
|
||||||
} from "@server/lib/validators";
|
} from "@server/lib/validators";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
|
|
||||||
// Define Zod schema for request parameters validation
|
// Define Zod schema for request parameters validation
|
||||||
const updateResourceRuleParamsSchema = z.strictObject({
|
const updateResourceRuleParamsSchema = z.strictObject({
|
||||||
@@ -25,7 +26,7 @@ const updateResourceRuleParamsSchema = z.strictObject({
|
|||||||
const updateResourceRuleSchema = z
|
const updateResourceRuleSchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(),
|
action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(),
|
||||||
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]).optional(),
|
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]).optional(),
|
||||||
value: z.string().min(1).optional(),
|
value: z.string().min(1).optional(),
|
||||||
priority: z.int(),
|
priority: z.int(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
@@ -166,6 +167,15 @@ export async function updateResourceRule(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (match === "REGION") {
|
||||||
|
if (!isValidRegionId(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid region ID provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { Input } from "@/components/ui/input";
|
|||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
@@ -75,6 +77,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { COUNTRIES } from "@server/db/countries";
|
import { COUNTRIES } from "@server/db/countries";
|
||||||
import { MAJOR_ASNS } from "@server/db/asns";
|
import { MAJOR_ASNS } from "@server/db/asns";
|
||||||
|
import { REGIONS, getRegionNameById, isValidRegionId } from "@server/db/regions";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -119,6 +122,8 @@ export default function ResourceRules(props: {
|
|||||||
useState(false);
|
useState(false);
|
||||||
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] =
|
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] =
|
||||||
|
useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -138,7 +143,8 @@ export default function ResourceRules(props: {
|
|||||||
IP: "IP",
|
IP: "IP",
|
||||||
CIDR: t("ipAddressRange"),
|
CIDR: t("ipAddressRange"),
|
||||||
COUNTRY: t("country"),
|
COUNTRY: t("country"),
|
||||||
ASN: "ASN"
|
ASN: "ASN",
|
||||||
|
REGION: t("region")
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const addRuleForm = useForm({
|
const addRuleForm = useForm({
|
||||||
@@ -258,6 +264,20 @@ export default function ResourceRules(props: {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
data.match === "REGION" &&
|
||||||
|
!isValidRegionId(data.value)
|
||||||
|
) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("rulesErrorInvalidRegion"),
|
||||||
|
description:
|
||||||
|
t("rulesErrorInvalidRegionDescription") ||
|
||||||
|
"Invalid region."
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// find the highest priority and add one
|
// find the highest priority and add one
|
||||||
let priority = data.priority;
|
let priority = data.priority;
|
||||||
@@ -311,6 +331,8 @@ export default function ResourceRules(props: {
|
|||||||
return t("rulesMatchCountry");
|
return t("rulesMatchCountry");
|
||||||
case "ASN":
|
case "ASN":
|
||||||
return "Enter an Autonomous System Number (e.g., AS15169 or 15169)";
|
return "Enter an Autonomous System Number (e.g., AS15169 or 15169)";
|
||||||
|
case "REGION":
|
||||||
|
return t("rulesMatchRegion");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,12 +558,12 @@ export default function ResourceRules(props: {
|
|||||||
<Select
|
<Select
|
||||||
defaultValue={row.original.match}
|
defaultValue={row.original.match}
|
||||||
onValueChange={(
|
onValueChange={(
|
||||||
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN"
|
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION"
|
||||||
) =>
|
) =>
|
||||||
updateRule(row.original.ruleId, {
|
updateRule(row.original.ruleId, {
|
||||||
match: value,
|
match: value,
|
||||||
value:
|
value:
|
||||||
value === "COUNTRY" ? "US" : value === "ASN" ? "AS15169" : row.original.value
|
value === "COUNTRY" ? "US" : value === "ASN" ? "AS15169" : value === "REGION" ? "021" : row.original.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -557,6 +579,11 @@ export default function ResourceRules(props: {
|
|||||||
{RuleMatch.COUNTRY}
|
{RuleMatch.COUNTRY}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
|
{isMaxmindAvailable && (
|
||||||
|
<SelectItem value="REGION">
|
||||||
|
{RuleMatch.REGION}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
{isMaxmindAsnAvailable && (
|
{isMaxmindAsnAvailable && (
|
||||||
<SelectItem value="ASN">
|
<SelectItem value="ASN">
|
||||||
{RuleMatch.ASN}
|
{RuleMatch.ASN}
|
||||||
@@ -638,14 +665,14 @@ export default function ResourceRules(props: {
|
|||||||
>
|
>
|
||||||
{row.original.value
|
{row.original.value
|
||||||
? (() => {
|
? (() => {
|
||||||
const found = MAJOR_ASNS.find(
|
const found = MAJOR_ASNS.find(
|
||||||
(asn) =>
|
(asn) =>
|
||||||
asn.code ===
|
asn.code ===
|
||||||
row.original.value
|
row.original.value
|
||||||
);
|
);
|
||||||
return found
|
return found
|
||||||
? `${found.name} (${row.original.value})`
|
? `${found.name} (${row.original.value})`
|
||||||
: `Custom (${row.original.value})`;
|
: `Custom (${row.original.value})`;
|
||||||
})()
|
})()
|
||||||
: "Select ASN"}
|
: "Select ASN"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
@@ -715,6 +742,88 @@ export default function ResourceRules(props: {
|
|||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
) : row.original.match === "REGION" ? (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="min-w-[200px] justify-between"
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const regionName = getRegionNameById(row.original.value);
|
||||||
|
if (!regionName) {
|
||||||
|
return t("selectRegion");
|
||||||
|
}
|
||||||
|
return `${t(regionName)} (${row.original.value})`;
|
||||||
|
})()}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="min-w-[200px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t("searchRegions")}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
{t("noRegionFound")}
|
||||||
|
</CommandEmpty>
|
||||||
|
{REGIONS.map((continent) => (
|
||||||
|
<CommandGroup key={continent.id} heading={t(continent.name)}>
|
||||||
|
<CommandItem
|
||||||
|
value={continent.id}
|
||||||
|
keywords={[
|
||||||
|
t(continent.name),
|
||||||
|
continent.id
|
||||||
|
]}
|
||||||
|
onSelect={() => {
|
||||||
|
updateRule(
|
||||||
|
row.original.ruleId,
|
||||||
|
{ value: continent.id }
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`mr-2 h-4 w-4 ${
|
||||||
|
row.original.value === continent.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{t(continent.name)} ({continent.id})
|
||||||
|
</CommandItem>
|
||||||
|
{continent.includes.map((subregion) => (
|
||||||
|
<CommandItem
|
||||||
|
key={subregion.id}
|
||||||
|
value={subregion.id}
|
||||||
|
keywords={[
|
||||||
|
t(subregion.name),
|
||||||
|
subregion.id
|
||||||
|
]}
|
||||||
|
onSelect={() => {
|
||||||
|
updateRule(
|
||||||
|
row.original.ruleId,
|
||||||
|
{ value: subregion.id }
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`mr-2 h-4 w-4 ${
|
||||||
|
row.original.value === subregion.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{t(subregion.name)} ({subregion.id})
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
defaultValue={row.original.value}
|
defaultValue={row.original.value}
|
||||||
@@ -925,6 +1034,13 @@ export default function ResourceRules(props: {
|
|||||||
}
|
}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
|
{isMaxmindAvailable && (
|
||||||
|
<SelectItem value="REGION">
|
||||||
|
{
|
||||||
|
RuleMatch.REGION
|
||||||
|
}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
{isMaxmindAsnAvailable && (
|
{isMaxmindAsnAvailable && (
|
||||||
<SelectItem value="ASN">
|
<SelectItem value="ASN">
|
||||||
{
|
{
|
||||||
@@ -1163,6 +1279,112 @@ export default function ResourceRules(props: {
|
|||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
) : addRuleForm.watch(
|
||||||
|
"match"
|
||||||
|
) === "REGION" ? (
|
||||||
|
<Popover
|
||||||
|
open={
|
||||||
|
openAddRuleRegionSelect
|
||||||
|
}
|
||||||
|
onOpenChange={
|
||||||
|
setOpenAddRuleRegionSelect
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PopoverTrigger
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={
|
||||||
|
openAddRuleRegionSelect
|
||||||
|
}
|
||||||
|
className="w-full justify-between"
|
||||||
|
>
|
||||||
|
{field.value
|
||||||
|
? (() => {
|
||||||
|
const regionName = getRegionNameById(field.value);
|
||||||
|
const translatedName = regionName ? t(regionName) : field.value;
|
||||||
|
return `${translatedName} (${field.value})`;
|
||||||
|
})()
|
||||||
|
: t(
|
||||||
|
"selectRegion"
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t(
|
||||||
|
"searchRegions"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
{t(
|
||||||
|
"noRegionFound"
|
||||||
|
)}
|
||||||
|
</CommandEmpty>
|
||||||
|
{REGIONS.map((continent) => (
|
||||||
|
<CommandGroup key={continent.id} heading={t(continent.name)}>
|
||||||
|
<CommandItem
|
||||||
|
value={continent.id}
|
||||||
|
keywords={[
|
||||||
|
t(continent.name),
|
||||||
|
continent.id
|
||||||
|
]}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(
|
||||||
|
continent.id
|
||||||
|
);
|
||||||
|
setOpenAddRuleRegionSelect(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`mr-2 h-4 w-4 ${
|
||||||
|
field.value === continent.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{t(continent.name)} ({continent.id})
|
||||||
|
</CommandItem>
|
||||||
|
{continent.includes.map((subregion) => (
|
||||||
|
<CommandItem
|
||||||
|
key={subregion.id}
|
||||||
|
value={subregion.id}
|
||||||
|
keywords={[
|
||||||
|
t(subregion.name),
|
||||||
|
subregion.id
|
||||||
|
]}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(
|
||||||
|
subregion.id
|
||||||
|
);
|
||||||
|
setOpenAddRuleRegionSelect(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`mr-2 h-4 w-4 ${
|
||||||
|
field.value === subregion.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{t(subregion.name)} ({subregion.id})
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
) : (
|
) : (
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user