Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions src/components/ui/CountrySelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,40 @@ 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;
iconSize?: number;
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(() => {
Expand All @@ -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
Expand All @@ -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}
/>
Expand Down
8 changes: 8 additions & 0 deletions src/interfaces/ReverseProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
94 changes: 91 additions & 3 deletions src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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 }),
};
}

Expand All @@ -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 {
Expand All @@ -193,6 +235,7 @@ export const ReverseProxyAccessControlRules = ({
onChange,
onValidationChange,
supportsCrowdSec,
isNewService,
}: Props) => {
const [rules, dispatch] = useReducer(
rulesReducer,
Expand All @@ -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],
Expand All @@ -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);
Expand All @@ -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>
Expand Down
1 change: 1 addition & 0 deletions src/modules/reverse-proxy/ReverseProxyModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,7 @@ export default function ReverseProxyModal({
onChange={setAccessRestrictions}
onValidationChange={setAccessControlHasErrors}
supportsCrowdSec={selectedDomain?.supports_crowdsec}
isNewService={!reverseProxy?.id}
/>
</div>
</TabsContent>
Expand Down
Loading