diff --git a/messages/de-DE.json b/messages/de-DE.json index 13ab3d11c..94950748f 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1734,6 +1734,34 @@ "exitNode": "Exit-Node", "country": "Land", "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": { "title": "Verwaltetes Selbsthosted", "description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen", diff --git a/messages/en-US.json b/messages/en-US.json index b023ac754..55cf2d6bd 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1734,6 +1734,40 @@ "exitNode": "Exit Node", "country": "Country", "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": { "title": "Managed Self-Hosted", "description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index d687b7837..5eb1ee076 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1734,6 +1734,34 @@ "exitNode": "Узел выхода", "country": "Страна", "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": { "title": "Управляемый с самовывоза", "description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками", diff --git a/server/db/regions.ts b/server/db/regions.ts new file mode 100644 index 000000000..90a380a29 --- /dev/null +++ b/server/db/regions.ts @@ -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; +} \ No newline at end of file diff --git a/server/routers/badger/verifySession.test.ts b/server/routers/badger/verifySession.test.ts index 7c967acef..8333a4578 100644 --- a/server/routers/badger/verifySession.test.ts +++ b/server/routers/badger/verifySession.test.ts @@ -1,4 +1,37 @@ 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 { // Normalize and split paths into segments @@ -272,12 +305,71 @@ function runTests() { "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 try { runTests(); + runRegionTests(); + console.log("\n✅ All tests passed!"); } catch (error) { - console.error("Test failed:", error); + console.error("❌ Test failed:", error); + process.exit(1); } diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 0e3a3489c..0bc9bde88 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -39,6 +39,7 @@ import { } from "#dynamic/lib/checkOrgAccessPolicy"; import { logRequestAudit } from "./logRequestAudit"; import cache from "@server/lib/cache"; +import { REGIONS } from "@server/db/regions"; const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string(), z.string()).optional(), @@ -967,6 +968,12 @@ async function checkRules( (await isIpInAsn(ipAsn, rule.value)) ) { 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; } +export async function isIpInRegion( + ipCountryCode: string | undefined, + checkRegionCode: string +): Promise { + 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 { const asnCacheKey = `asn:${ip}`; diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index a516d14af..0796191b1 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -14,10 +14,11 @@ import { isValidUrlGlobPattern } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { isValidRegionId } from "@server/db/regions"; const createResourceRuleSchema = z.strictObject({ 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), priority: z.int(), 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 diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index b443bd1c2..fe717bb5e 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -14,6 +14,7 @@ import { isValidUrlGlobPattern } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { isValidRegionId } from "@server/db/regions"; // Define Zod schema for request parameters validation const updateResourceRuleParamsSchema = z.strictObject({ @@ -25,7 +26,7 @@ const updateResourceRuleParamsSchema = z.strictObject({ const updateResourceRuleSchema = z .strictObject({ 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(), priority: z.int(), 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" + ) + ); + } } } 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 d10a38d65..c00f9e0df 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx @@ -6,7 +6,9 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -75,6 +77,7 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { COUNTRIES } from "@server/db/countries"; import { MAJOR_ASNS } from "@server/db/asns"; +import { REGIONS, getRegionNameById, isValidRegionId } from "@server/db/regions"; import { Command, CommandEmpty, @@ -119,6 +122,8 @@ export default function ResourceRules(props: { useState(false); const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] = + useState(false); const router = useRouter(); const t = useTranslations(); const { env } = useEnvContext(); @@ -138,7 +143,8 @@ export default function ResourceRules(props: { IP: "IP", CIDR: t("ipAddressRange"), COUNTRY: t("country"), - ASN: "ASN" + ASN: "ASN", + REGION: t("region") } as const; const addRuleForm = useForm({ @@ -258,6 +264,20 @@ export default function ResourceRules(props: { setLoading(false); 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 let priority = data.priority; @@ -311,6 +331,8 @@ export default function ResourceRules(props: { return t("rulesMatchCountry"); case "ASN": 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: { )} + {isMaxmindAvailable && ( + + { + RuleMatch.REGION + } + + )} {isMaxmindAsnAvailable && ( { @@ -1163,6 +1279,112 @@ export default function ResourceRules(props: { + ) : addRuleForm.watch( + "match" + ) === "REGION" ? ( + + + + + + + + + + {t( + "noRegionFound" + )} + + {REGIONS.map((continent) => ( + + { + field.onChange( + continent.id + ); + setOpenAddRuleRegionSelect( + false + ); + }} + > + + {t(continent.name)} ({continent.id}) + + {continent.includes.map((subregion) => ( + { + field.onChange( + subregion.id + ); + setOpenAddRuleRegionSelect( + false + ); + }} + > + + {t(subregion.name)} ({subregion.id}) + + ))} + + ))} + + + + ) : ( )}