Compare commits

...
4 changed files with 142 additions and 5 deletions

No files matched your search

+42 -2
View File
@@ -6,6 +6,26 @@ import { createElement, useMemo } from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { useCountries } from "@/contexts/CountryProvider";
// browserCountryCode returns the ISO 3166-1 alpha-2 region from the browser's
// preferred languages (e.g. "en-US" -> "US"), or undefined when none carries a
// region. It reads navigator.languages, so it is client-only.
function browserCountryCode(): string | undefined {
if (typeof navigator === "undefined") return undefined;
const langs = navigator.languages?.length
? navigator.languages
: [navigator.language];
for (const lang of langs) {
if (!lang) continue;
try {
const region = new Intl.Locale(lang).region;
if (region) return region.toUpperCase();
} catch {
// Ignore malformed language tags and try the next one.
}
}
return undefined;
}
type Props = {
value: string;
onChange: (value: string) => void;
@@ -13,7 +33,13 @@ type Props = {
popoverWidth?: "auto" | "content" | number;
truncate?: boolean;
};
export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => {
export const CountrySelector = ({
value,
onChange,
iconSize = 20,
popoverWidth,
truncate,
}: Props) => {
const { countries, isLoading } = useCountries();
const countryList = useMemo(() => {
@@ -36,6 +62,20 @@ export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth,
}) as SelectOption[];
}, [countries]);
// Surface the browser-detected country at the top so the common case is one
// click away. Falls back to the original order when detection fails or the
// code is not in the list.
const orderedList = useMemo(() => {
if (!countryList?.length) return countryList;
const code = browserCountryCode();
if (!code) return countryList;
const index = countryList.findIndex((option) => option.value === code);
if (index <= 0) return countryList;
const reordered = countryList.slice();
const [detected] = reordered.splice(index, 1);
return [detected, ...reordered];
}, [countryList]);
return (
<div className={"block w-full"}>
<SelectDropdown
@@ -46,7 +86,7 @@ export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth,
value={value}
onChange={onChange}
iconSize={iconSize}
options={countryList || []}
options={orderedList || []}
popoverWidth={popoverWidth}
truncate={truncate}
/>
+8
View File
@@ -33,12 +33,20 @@ export const CrowdSecMode = {
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];
export const AllowMatch = {
ALL: "all",
ANY: "any",
} as const;
export type AllowMatch = (typeof AllowMatch)[keyof typeof AllowMatch];
export interface AccessRestrictions {
allowed_cidrs?: string[];
blocked_cidrs?: string[];
allowed_countries?: string[];
blocked_countries?: string[];
crowdsec_mode?: CrowdSecMode;
allow_match?: AllowMatch;
}
export interface ReverseProxyMeta {
@@ -1,12 +1,15 @@
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import FullTooltip from "@components/FullTooltip";
import { ToggleSwitch } from "@components/ToggleSwitch";
import Button from "@components/Button";
import { Input } from "@components/Input";
import cidr from "ip-cidr";
import { hostSuffixFor, isIPv6 } from "@utils/ip";
import {
FlagIcon,
InfoIcon,
MinusCircleIcon,
NetworkIcon,
PlusIcon,
@@ -19,7 +22,11 @@ import {
SelectOption,
} from "@components/select/SelectDropdown";
import { CountrySelector } from "@/components/ui/CountrySelector";
import { AccessRestrictions, CrowdSecMode } from "@/interfaces/ReverseProxy";
import {
AccessRestrictions,
AllowMatch,
CrowdSecMode,
} from "@/interfaces/ReverseProxy";
import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation";
type AccessAction = "allow" | "block";
@@ -118,9 +125,33 @@ function restrictionsToRules(
return rules;
}
// allowSpansCategories reports whether the allow rules cover both a location
// (country) and an IP/CIDR category. The all-vs-any combine mode only changes
// the outcome in that case, so allow_match is only persisted then.
//
// requireValue distinguishes the two callers: persistence needs a real value
// (an empty rule contributes no allowlist), while the toggle's visibility keys
// off the selected type alone so it appears as soon as a category is chosen,
// before the user finishes typing.
function allowSpansCategories(
rules: AccessRule[],
requireValue: boolean,
): boolean {
let hasCountry = false;
let hasCidr = false;
for (const rule of rules) {
if (rule.action !== "allow") continue;
if (requireValue && !rule.value) continue;
if (rule.type === "country") hasCountry = true;
else hasCidr = true;
}
return hasCountry && hasCidr;
}
function rulesToRestrictions(
rules: AccessRule[],
crowdsecMode?: CrowdSecMode,
allowMatch?: AllowMatch,
): AccessRestrictions | undefined {
const allowed_countries: string[] = [];
const blocked_countries: string[] = [];
@@ -153,12 +184,19 @@ function rulesToRestrictions(
if (!hasAny) return undefined;
// Only emit allow_match when "any" is selected and the allow rules actually
// span both categories; otherwise the mode is a no-op and the backend
// defaults to "all".
const emitAllowMatch =
allowMatch === AllowMatch.ANY && allowSpansCategories(rules, true);
return {
...(allowed_countries.length > 0 && { allowed_countries }),
...(blocked_countries.length > 0 && { blocked_countries }),
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
...(emitAllowMatch && { allow_match: AllowMatch.ANY }),
};
}
@@ -167,6 +205,10 @@ type Props = {
onChange: (value: AccessRestrictions | undefined) => void;
onValidationChange?: (hasErrors: boolean) => void;
supportsCrowdSec?: boolean;
// isNewService selects the default allow-combine mode: new services default
// to "any" (OR across categories), while existing services without a stored
// allow_match keep "all" (AND) for backward compatibility.
isNewService?: boolean;
};
function validateRule(rule: AccessRule): string {
@@ -193,6 +235,7 @@ export const ReverseProxyAccessControlRules = ({
onChange,
onValidationChange,
supportsCrowdSec,
isNewService,
}: Props) => {
const [rules, dispatch] = useReducer(
rulesReducer,
@@ -204,6 +247,15 @@ export const ReverseProxyAccessControlRules = ({
value?.crowdsec_mode ?? CrowdSecMode.OFF,
);
const [allowMatch, setAllowMatch] = useState<AllowMatch>(
value?.allow_match ?? (isNewService ? AllowMatch.ANY : AllowMatch.ALL),
);
const showAllowMatch = useMemo(
() => allowSpansCategories(rules, false),
[rules],
);
const errors = useMemo(
() => Object.fromEntries(rules.map((r) => [r.id, validateRule(r)])),
[rules],
@@ -227,8 +279,8 @@ export const ReverseProxyAccessControlRules = ({
}, [supportsCrowdSec]);
useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode));
}, [rules, crowdsecMode]);
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode, allowMatch));
}, [rules, crowdsecMode, allowMatch]);
useEffect(() => {
onValidationChangeRef.current?.(hasErrors);
@@ -242,6 +294,42 @@ export const ReverseProxyAccessControlRules = ({
onChange={setCrowdsecMode}
/>
)}
<div
className={`flex items-center justify-between gap-4 mb-4 ${
showAllowMatch ? "" : "opacity-60"
}`}
data-testid="allow-match"
>
<div>
<Label className="flex items-center gap-2">
Require all allow rules
<FullTooltip
content={
<div className="text-xs max-w-xs">
When on, a connection must match every allow category (an
allowed country and an allowed IP/CIDR). When off, matching
any one is enough. Block rules always take priority.
</div>
}
>
<InfoIcon size={14} className="text-nb-gray-500" />
</FullTooltip>
</Label>
<HelpText margin={false}>
{showAllowMatch
? "Off: match any allow rule. On: match all of them."
: "Applies once you have both a country and an IP/CIDR allow rule."}
</HelpText>
</div>
<ToggleSwitch
checked={allowMatch === AllowMatch.ALL}
onCheckedChange={(checked) =>
setAllowMatch(checked ? AllowMatch.ALL : AllowMatch.ANY)
}
disabled={!showAllowMatch}
data-testid="allow-match-toggle"
/>
</div>
<div>
<Label>Access Control Rules</Label>
<HelpText>
@@ -814,6 +814,7 @@ export default function ReverseProxyModal({
onChange={setAccessRestrictions}
onValidationChange={setAccessControlHasErrors}
supportsCrowdSec={selectedDomain?.supports_crowdsec}
isNewService={!reverseProxy?.id}
/>
</div>
</TabsContent>