mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a67a23fc1f | ||
|
|
5a5e04ad35 | ||
|
|
598ecb196d |
No files matched your search
+3
-3
@@ -1,8 +1,8 @@
|
||||
[
|
||||
{
|
||||
"tag": "Preview",
|
||||
"text": "The new NetBird desktop app is here - now available as a 0.75 release candidate.",
|
||||
"link": "https://github.com/netbirdio/netbird/discussions/6483",
|
||||
"tag": "New",
|
||||
"text": "The new NetBird desktop app is here - now available in the v0.75 release.",
|
||||
"link": "https://netbird.io/knowledge-hub/netbird-v0-75-new-desktop-app",
|
||||
"linkText": "Learn more",
|
||||
"variant": "important",
|
||||
"isExternal": true,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -104,13 +104,10 @@ export const isAgentNetworkOnly = () => {
|
||||
};
|
||||
|
||||
// pkgsDownloadUrl builds a NetBird client installer download link on
|
||||
// pkgs.netbird.io. In Agent Network-only mode the client ships from the
|
||||
// release-candidate channel, so the link gets a "/rc" suffix that
|
||||
// pkgs.netbird.io 302-redirects to the latest RC GitHub asset (e.g.
|
||||
// "windows/x64" -> "windows/x64/rc"). `path` is the platform path without a
|
||||
// pkgs.netbird.io. `path` is the platform path without a
|
||||
// leading slash, e.g. "windows/x64" or "macos/universal".
|
||||
export const pkgsDownloadUrl = (path: string) =>
|
||||
`https://pkgs.netbird.io/${path}${isAgentNetworkOnly() ? "/rc" : ""}`;
|
||||
`https://pkgs.netbird.io/${path}`;
|
||||
|
||||
// isAgentNetworkEnabled returns true when the Agent Network product surface
|
||||
// (Providers, Policies, Usage & Logs) is available — in either the dedicated
|
||||
|
||||
Reference in new issue
Block a user