Compare commits

...
Author SHA1 Message Date
Viktor Liu a0f4a3564a Add CrowdSec AppSec mode to reverse proxy access control 2026-07-27 15:56:09 +02:00
8 changed files with 219 additions and 16 deletions

No files matched your search

+11
View File
@@ -33,12 +33,21 @@ export const CrowdSecMode = {
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode]; export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];
export const AppSecMode = {
OFF: "off",
ENFORCE: "enforce",
OBSERVE: "observe",
} as const;
export type AppSecMode = (typeof AppSecMode)[keyof typeof AppSecMode];
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;
appsec_mode?: AppSecMode;
} }
export interface ReverseProxyMeta { export interface ReverseProxyMeta {
@@ -122,6 +131,7 @@ export interface ReverseProxyDomain {
supports_custom_ports?: boolean; supports_custom_ports?: boolean;
require_subdomain?: boolean; require_subdomain?: boolean;
supports_crowdsec?: boolean; supports_crowdsec?: boolean;
supports_appsec?: boolean;
supports_private?: boolean; supports_private?: boolean;
} }
@@ -188,6 +198,7 @@ export interface ReverseProxyCluster {
supports_custom_ports?: boolean; supports_custom_ports?: boolean;
require_subdomain?: boolean; require_subdomain?: boolean;
supports_crowdsec?: boolean; supports_crowdsec?: boolean;
supports_appsec?: boolean;
// True when at least one connected proxy in this cluster is running embedded // True when at least one connected proxy in this cluster is running embedded
// in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel. // in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel.
// Lets the dashboard distinguish per-peer / private clusters from centralised // Lets the dashboard distinguish per-peer / private clusters from centralised
@@ -19,8 +19,13 @@ 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,
AppSecMode,
CrowdSecMode,
} from "@/interfaces/ReverseProxy";
import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation"; import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation";
import { ReverseProxyAppSecInspection } from "@/modules/reverse-proxy/ReverseProxyAppSecInspection";
type AccessAction = "allow" | "block"; type AccessAction = "allow" | "block";
type AccessRuleType = "country" | "ip" | "cidr"; type AccessRuleType = "country" | "ip" | "cidr";
@@ -121,6 +126,7 @@ function restrictionsToRules(
function rulesToRestrictions( function rulesToRestrictions(
rules: AccessRule[], rules: AccessRule[],
crowdsecMode?: CrowdSecMode, crowdsecMode?: CrowdSecMode,
appsecMode?: AppSecMode,
): AccessRestrictions | undefined { ): AccessRestrictions | undefined {
const allowed_countries: string[] = []; const allowed_countries: string[] = [];
const blocked_countries: string[] = []; const blocked_countries: string[] = [];
@@ -144,12 +150,14 @@ function rulesToRestrictions(
} }
const hasCrowdSec = crowdsecMode != null && crowdsecMode !== CrowdSecMode.OFF; const hasCrowdSec = crowdsecMode != null && crowdsecMode !== CrowdSecMode.OFF;
const hasAppSec = appsecMode != null && appsecMode !== AppSecMode.OFF;
const hasAny = const hasAny =
allowed_countries.length > 0 || allowed_countries.length > 0 ||
blocked_countries.length > 0 || blocked_countries.length > 0 ||
allowed_cidrs.length > 0 || allowed_cidrs.length > 0 ||
blocked_cidrs.length > 0 || blocked_cidrs.length > 0 ||
hasCrowdSec; hasCrowdSec ||
hasAppSec;
if (!hasAny) return undefined; if (!hasAny) return undefined;
@@ -159,6 +167,7 @@ function rulesToRestrictions(
...(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 }),
...(hasAppSec && { appsec_mode: appsecMode }),
}; };
} }
@@ -167,6 +176,7 @@ type Props = {
onChange: (value: AccessRestrictions | undefined) => void; onChange: (value: AccessRestrictions | undefined) => void;
onValidationChange?: (hasErrors: boolean) => void; onValidationChange?: (hasErrors: boolean) => void;
supportsCrowdSec?: boolean; supportsCrowdSec?: boolean;
supportsAppSec?: boolean;
}; };
function validateRule(rule: AccessRule): string { function validateRule(rule: AccessRule): string {
@@ -193,6 +203,7 @@ export const ReverseProxyAccessControlRules = ({
onChange, onChange,
onValidationChange, onValidationChange,
supportsCrowdSec, supportsCrowdSec,
supportsAppSec,
}: Props) => { }: Props) => {
const [rules, dispatch] = useReducer( const [rules, dispatch] = useReducer(
rulesReducer, rulesReducer,
@@ -200,6 +211,10 @@ export const ReverseProxyAccessControlRules = ({
restrictionsToRules, restrictionsToRules,
); );
const [appsecMode, setAppsecMode] = useState<AppSecMode>(
value?.appsec_mode ?? AppSecMode.OFF,
);
const [crowdsecMode, setCrowdsecMode] = useState<CrowdSecMode>( const [crowdsecMode, setCrowdsecMode] = useState<CrowdSecMode>(
value?.crowdsec_mode ?? CrowdSecMode.OFF, value?.crowdsec_mode ?? CrowdSecMode.OFF,
); );
@@ -227,8 +242,14 @@ export const ReverseProxyAccessControlRules = ({
}, [supportsCrowdSec]); }, [supportsCrowdSec]);
useEffect(() => { useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode)); if (!supportsAppSec) {
}, [rules, crowdsecMode]); setAppsecMode(AppSecMode.OFF);
}
}, [supportsAppSec]);
useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode, appsecMode));
}, [rules, crowdsecMode, appsecMode]);
useEffect(() => { useEffect(() => {
onValidationChangeRef.current?.(hasErrors); onValidationChangeRef.current?.(hasErrors);
@@ -242,6 +263,12 @@ export const ReverseProxyAccessControlRules = ({
onChange={setCrowdsecMode} onChange={setCrowdsecMode}
/> />
)} )}
{supportsAppSec && (
<ReverseProxyAppSecInspection
value={appsecMode}
onChange={setAppsecMode}
/>
)}
<div> <div>
<Label>Access Control Rules</Label> <Label>Access Control Rules</Label>
<HelpText> <HelpText>
@@ -0,0 +1,107 @@
import * as React from "react";
import { ReactNode } from "react";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { EyeIcon, PowerOffIcon, ShieldCheckIcon } from "lucide-react";
import { HelpTooltip } from "@components/HelpTooltip";
import { AppSecMode } from "@/interfaces/ReverseProxy";
import Image from "next/image";
import CrowdSecIconImage from "@/assets/integrations/crowdsec.png";
type Props = {
value: AppSecMode;
onChange: (value: AppSecMode) => void;
};
type AppSecOption = {
label: string;
description?: string;
icon: ReactNode;
};
const APPSEC_OPTIONS: Record<AppSecMode, AppSecOption> = {
[AppSecMode.OFF]: {
label: "Disabled",
icon: <PowerOffIcon size={14} />,
},
[AppSecMode.ENFORCE]: {
label: "Enforce",
description:
"Flagged requests are blocked with 403. Every request is inspected before it reaches the service, and requests are denied if the engine is unreachable (fail-closed).",
icon: <ShieldCheckIcon size={14} />,
},
[AppSecMode.OBSERVE]: {
label: "Observe",
description:
"Flagged requests are logged but still forwarded. Use this to evaluate the rules against real traffic before enforcing.",
icon: <EyeIcon size={14} />,
},
};
export const ReverseProxyAppSecInspection = ({ value, onChange }: Props) => {
const selected = APPSEC_OPTIONS[value];
return (
<div className="flex items-center gap-0 justify-between mb-6">
<div className="flex gap-4">
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70 shrink-0 relative"
}
>
<Image
src={CrowdSecIconImage}
alt={"CrowdSec"}
className={"rounded-[4px]"}
/>
</div>
<div>
<Label>CrowdSec AppSec (WAF)</Label>
<HelpText>
Inspect HTTP requests for exploits.{" "}
<b className={"text-white"}>Enforce</b> to block them or{" "}
<b className={"text-white"}>Observe</b> to only log without
blocking.
</HelpText>
</div>
</div>
<Select value={value} onValueChange={(v) => onChange(v as AppSecMode)}>
<SelectTrigger className="w-[260px]" data-testid="appsec-mode-trigger">
<div className="flex items-center gap-2 whitespace-nowrap">
{selected.icon}
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
{Object.entries(APPSEC_OPTIONS).map(([mode, config]) => (
<SelectItem
key={mode}
value={mode}
data-testid={`appsec-mode-${mode}`}
extra={
config.description ? (
<HelpTooltip
triggerClassName="ml-[0.01rem]"
align="center"
side="right"
content={<>{config.description}</>}
/>
) : undefined
}
>
<span className="whitespace-nowrap">{config.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
@@ -814,6 +814,7 @@ export default function ReverseProxyModal({
onChange={setAccessRestrictions} onChange={setAccessRestrictions}
onValidationChange={setAccessControlHasErrors} onValidationChange={setAccessControlHasErrors}
supportsCrowdSec={selectedDomain?.supports_crowdsec} supportsCrowdSec={selectedDomain?.supports_crowdsec}
supportsAppSec={!isL4Mode && selectedDomain?.supports_appsec}
/> />
</div> </div>
</TabsContent> </TabsContent>
@@ -47,6 +47,15 @@ export default function ClustersFeaturesCell({ cluster }: Readonly<Props>) {
icon: <ShieldAlert size={14} className={"text-green-500"} />, icon: <ShieldAlert size={14} className={"text-green-500"} />,
}); });
} }
if (cluster.supports_appsec) {
features.push({
key: "appsec",
label: "AppSec (WAF)",
description:
"Cluster has a CrowdSec AppSec endpoint configured across all active proxies, so services can inspect HTTP requests for exploits.",
icon: <ShieldAlert size={14} className={"text-green-500"} />,
});
}
if (cluster.private) { if (cluster.private) {
features.push({ features.push({
key: "private", key: "private",
@@ -56,8 +65,7 @@ export default function ClustersFeaturesCell({ cluster }: Readonly<Props>) {
Lets you publish services that are only reachable from peers in your Lets you publish services that are only reachable from peers in your
NetBird network. Required for{" "} NetBird network. Required for{" "}
<span className={"font-medium text-white"}>NetBird-Only Access</span>{" "} <span className={"font-medium text-white"}>NetBird-Only Access</span>{" "}
and{" "} and <span className={"font-medium text-white"}>Proxy Cluster</span>{" "}
<span className={"font-medium text-white"}>Proxy Cluster</span>{" "}
target types. target types.
</> </>
), ),
@@ -111,6 +111,21 @@ export const ReverseProxyEventsAuthMethodCell = ({
icon: <ShieldOff size={12} />, icon: <ShieldOff size={12} />,
label: "CrowdSec Unavailable", label: "CrowdSec Unavailable",
}; };
case "appsec_ban":
return {
icon: <ShieldAlert size={12} />,
label: "AppSec Ban",
};
case "appsec_captcha":
return {
icon: <ShieldAlert size={12} />,
label: "AppSec Captcha",
};
case "appsec_unavailable":
return {
icon: <ShieldOff size={12} />,
label: "AppSec Unavailable",
};
default: default:
return { return {
icon: null, icon: null,
@@ -9,6 +9,9 @@ const VERDICT_LABELS: Record<string, string> = {
crowdsec_ban: "Ban", crowdsec_ban: "Ban",
crowdsec_captcha: "Captcha", crowdsec_captcha: "Captcha",
crowdsec_throttle: "Throttle", crowdsec_throttle: "Throttle",
appsec_ban: "Ban",
appsec_captcha: "Captcha",
appsec_unavailable: "Unavailable",
}; };
type Props = { type Props = {
@@ -17,12 +20,16 @@ type Props = {
export const ReverseProxyEventsReasonCell = ({ event }: Props) => { export const ReverseProxyEventsReasonCell = ({ event }: Props) => {
const metadata = event.metadata; const metadata = event.metadata;
const verdict = metadata?.crowdsec_verdict; // Only one of the two can be the reason a request was flagged without being
// blocked, since an enforced block sets auth_method_used instead.
const source = metadata?.appsec_verdict ? "appsec" : "crowdsec";
const verdictKey = `${source}_verdict`;
const verdict = metadata?.[verdictKey];
if (verdict && !event.auth_method_used?.startsWith("crowdsec_")) { if (verdict && !event.auth_method_used?.startsWith(`${source}_`)) {
const verdictLabel = VERDICT_LABELS[verdict] ?? verdict; const verdictLabel = VERDICT_LABELS[verdict] ?? verdict;
const metaEntries = Object.entries(metadata!).filter( const metaEntries = Object.entries(metadata!).filter(
([k]) => k !== "crowdsec_verdict", ([k]) => k !== verdictKey,
); );
return ( return (
@@ -49,7 +56,8 @@ export const ReverseProxyEventsReasonCell = ({ event }: Props) => {
<div className="px-3 py-2"> <div className="px-3 py-2">
<Badge variant="gray" className="gap-1.5"> <Badge variant="gray" className="gap-1.5">
<ShieldAlert size={12} className="text-yellow-500" /> <ShieldAlert size={12} className="text-yellow-500" />
CrowdSec Observe: {verdictLabel} {source === "appsec" ? "AppSec" : "CrowdSec"} Observe:{" "}
{verdictLabel}
</Badge> </Badge>
</div> </div>
</FullTooltip> </FullTooltip>
@@ -20,7 +20,7 @@ import { useMemo } from "react";
import { useCountries } from "@/contexts/CountryProvider"; import { useCountries } from "@/contexts/CountryProvider";
import { usePermissions } from "@/contexts/PermissionsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider"; import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy"; import { AppSecMode, CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy";
type RuleEntry = { type RuleEntry = {
key: string; key: string;
@@ -44,21 +44,35 @@ export default function ReverseProxyAccessControlCell({
const canConfigure = !!permission?.services?.update; const canConfigure = !!permission?.services?.update;
const restrictions = reverseProxy.access_restrictions; const restrictions = reverseProxy.access_restrictions;
const supportsCrowdSec = domains?.find( // Services on a custom domain carry its target_cluster in proxy_cluster, so
(d) => d.domain === reverseProxy.proxy_cluster, // matching on domain alone misses them and reports no capability.
)?.supports_crowdsec; const clusterDomain = domains?.find(
(d) =>
d.domain === reverseProxy.proxy_cluster ||
d.target_cluster === reverseProxy.proxy_cluster,
);
const supportsCrowdSec = clusterDomain?.supports_crowdsec;
const hasCrowdSec = const hasCrowdSec =
supportsCrowdSec && supportsCrowdSec &&
restrictions?.crowdsec_mode != null && restrictions?.crowdsec_mode != null &&
restrictions.crowdsec_mode !== CrowdSecMode.OFF; restrictions.crowdsec_mode !== CrowdSecMode.OFF;
const supportsAppSec = clusterDomain?.supports_appsec;
const hasAppSec =
supportsAppSec &&
restrictions?.appsec_mode != null &&
restrictions.appsec_mode !== AppSecMode.OFF;
const ruleCount = const ruleCount =
(restrictions?.allowed_cidrs?.length ?? 0) + (restrictions?.allowed_cidrs?.length ?? 0) +
(restrictions?.blocked_cidrs?.length ?? 0) + (restrictions?.blocked_cidrs?.length ?? 0) +
(restrictions?.allowed_countries?.length ?? 0) + (restrictions?.allowed_countries?.length ?? 0) +
(restrictions?.blocked_countries?.length ?? 0) + (restrictions?.blocked_countries?.length ?? 0) +
(hasCrowdSec ? 1 : 0); (hasCrowdSec ? 1 : 0) +
(hasAppSec ? 1 : 0);
const rulesBadge = ( const rulesBadge = (
<Badge <Badge
@@ -163,8 +177,20 @@ export default function ReverseProxyAccessControlCell({
}); });
} }
if (hasAppSec) {
entries.push({
key: "appsec",
label: "AppSec (WAF)",
Icon: ShieldAlert,
value:
restrictions?.appsec_mode === AppSecMode.ENFORCE
? "Enforce"
: "Observe",
});
}
return entries; return entries;
}, [restrictions, countries, hasCrowdSec]); }, [restrictions, countries, hasCrowdSec, hasAppSec]);
const showRulesHover = ruleGroups.length > 0; const showRulesHover = ruleGroups.length > 0;