mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a67a23fc1f |
No files matched your search
@@ -6,6 +6,26 @@ import { createElement, useMemo } from "react";
|
|||||||
import RoundedFlag from "@/assets/countries/RoundedFlag";
|
import RoundedFlag from "@/assets/countries/RoundedFlag";
|
||||||
import { useCountries } from "@/contexts/CountryProvider";
|
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 = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
@@ -13,7 +33,13 @@ type Props = {
|
|||||||
popoverWidth?: "auto" | "content" | number;
|
popoverWidth?: "auto" | "content" | number;
|
||||||
truncate?: boolean;
|
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 { countries, isLoading } = useCountries();
|
||||||
|
|
||||||
const countryList = useMemo(() => {
|
const countryList = useMemo(() => {
|
||||||
@@ -36,6 +62,20 @@ export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth,
|
|||||||
}) as SelectOption[];
|
}) as SelectOption[];
|
||||||
}, [countries]);
|
}, [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 (
|
return (
|
||||||
<div className={"block w-full"}>
|
<div className={"block w-full"}>
|
||||||
<SelectDropdown
|
<SelectDropdown
|
||||||
@@ -46,7 +86,7 @@ export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth,
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
iconSize={iconSize}
|
iconSize={iconSize}
|
||||||
options={countryList || []}
|
options={orderedList || []}
|
||||||
popoverWidth={popoverWidth}
|
popoverWidth={popoverWidth}
|
||||||
truncate={truncate}
|
truncate={truncate}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -33,12 +33,20 @@ export const CrowdSecMode = {
|
|||||||
|
|
||||||
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof 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 {
|
export interface AccessRestrictions {
|
||||||
allowed_cidrs?: string[];
|
allowed_cidrs?: string[];
|
||||||
blocked_cidrs?: string[];
|
blocked_cidrs?: string[];
|
||||||
allowed_countries?: string[];
|
allowed_countries?: string[];
|
||||||
blocked_countries?: string[];
|
blocked_countries?: string[];
|
||||||
crowdsec_mode?: CrowdSecMode;
|
crowdsec_mode?: CrowdSecMode;
|
||||||
|
allow_match?: AllowMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReverseProxyMeta {
|
export interface ReverseProxyMeta {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||||
import { Label } from "@components/Label";
|
import { Label } from "@components/Label";
|
||||||
import HelpText from "@components/HelpText";
|
import HelpText from "@components/HelpText";
|
||||||
|
import FullTooltip from "@components/FullTooltip";
|
||||||
|
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||||
import Button from "@components/Button";
|
import Button from "@components/Button";
|
||||||
import { Input } from "@components/Input";
|
import { Input } from "@components/Input";
|
||||||
import cidr from "ip-cidr";
|
import cidr from "ip-cidr";
|
||||||
import { hostSuffixFor, isIPv6 } from "@utils/ip";
|
import { hostSuffixFor, isIPv6 } from "@utils/ip";
|
||||||
import {
|
import {
|
||||||
FlagIcon,
|
FlagIcon,
|
||||||
|
InfoIcon,
|
||||||
MinusCircleIcon,
|
MinusCircleIcon,
|
||||||
NetworkIcon,
|
NetworkIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
@@ -19,7 +22,11 @@ import {
|
|||||||
SelectOption,
|
SelectOption,
|
||||||
} from "@components/select/SelectDropdown";
|
} from "@components/select/SelectDropdown";
|
||||||
import { CountrySelector } from "@/components/ui/CountrySelector";
|
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";
|
import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation";
|
||||||
|
|
||||||
type AccessAction = "allow" | "block";
|
type AccessAction = "allow" | "block";
|
||||||
@@ -118,9 +125,33 @@ function restrictionsToRules(
|
|||||||
return rules;
|
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(
|
function rulesToRestrictions(
|
||||||
rules: AccessRule[],
|
rules: AccessRule[],
|
||||||
crowdsecMode?: CrowdSecMode,
|
crowdsecMode?: CrowdSecMode,
|
||||||
|
allowMatch?: AllowMatch,
|
||||||
): AccessRestrictions | undefined {
|
): AccessRestrictions | undefined {
|
||||||
const allowed_countries: string[] = [];
|
const allowed_countries: string[] = [];
|
||||||
const blocked_countries: string[] = [];
|
const blocked_countries: string[] = [];
|
||||||
@@ -153,12 +184,19 @@ function rulesToRestrictions(
|
|||||||
|
|
||||||
if (!hasAny) return undefined;
|
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 {
|
return {
|
||||||
...(allowed_countries.length > 0 && { allowed_countries }),
|
...(allowed_countries.length > 0 && { allowed_countries }),
|
||||||
...(blocked_countries.length > 0 && { blocked_countries }),
|
...(blocked_countries.length > 0 && { blocked_countries }),
|
||||||
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
|
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
|
||||||
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
|
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
|
||||||
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
|
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
|
||||||
|
...(emitAllowMatch && { allow_match: AllowMatch.ANY }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +205,10 @@ type Props = {
|
|||||||
onChange: (value: AccessRestrictions | undefined) => void;
|
onChange: (value: AccessRestrictions | undefined) => void;
|
||||||
onValidationChange?: (hasErrors: boolean) => void;
|
onValidationChange?: (hasErrors: boolean) => void;
|
||||||
supportsCrowdSec?: boolean;
|
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 {
|
function validateRule(rule: AccessRule): string {
|
||||||
@@ -193,6 +235,7 @@ export const ReverseProxyAccessControlRules = ({
|
|||||||
onChange,
|
onChange,
|
||||||
onValidationChange,
|
onValidationChange,
|
||||||
supportsCrowdSec,
|
supportsCrowdSec,
|
||||||
|
isNewService,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [rules, dispatch] = useReducer(
|
const [rules, dispatch] = useReducer(
|
||||||
rulesReducer,
|
rulesReducer,
|
||||||
@@ -204,6 +247,15 @@ export const ReverseProxyAccessControlRules = ({
|
|||||||
value?.crowdsec_mode ?? CrowdSecMode.OFF,
|
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(
|
const errors = useMemo(
|
||||||
() => Object.fromEntries(rules.map((r) => [r.id, validateRule(r)])),
|
() => Object.fromEntries(rules.map((r) => [r.id, validateRule(r)])),
|
||||||
[rules],
|
[rules],
|
||||||
@@ -227,8 +279,8 @@ export const ReverseProxyAccessControlRules = ({
|
|||||||
}, [supportsCrowdSec]);
|
}, [supportsCrowdSec]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode));
|
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode, allowMatch));
|
||||||
}, [rules, crowdsecMode]);
|
}, [rules, crowdsecMode, allowMatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onValidationChangeRef.current?.(hasErrors);
|
onValidationChangeRef.current?.(hasErrors);
|
||||||
@@ -242,6 +294,42 @@ export const ReverseProxyAccessControlRules = ({
|
|||||||
onChange={setCrowdsecMode}
|
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>
|
<div>
|
||||||
<Label>Access Control Rules</Label>
|
<Label>Access Control Rules</Label>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
|
|||||||
@@ -814,6 +814,7 @@ export default function ReverseProxyModal({
|
|||||||
onChange={setAccessRestrictions}
|
onChange={setAccessRestrictions}
|
||||||
onValidationChange={setAccessControlHasErrors}
|
onValidationChange={setAccessControlHasErrors}
|
||||||
supportsCrowdSec={selectedDomain?.supports_crowdsec}
|
supportsCrowdSec={selectedDomain?.supports_crowdsec}
|
||||||
|
isNewService={!reverseProxy?.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
Reference in new issue
Block a user