Skip to content
Merged
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
Binary file added src/assets/integrations/crowdsec.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/interfaces/ReverseProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,20 @@ export interface ReverseProxy {
meta?: ReverseProxyMeta;
}

export const CrowdSecMode = {
OFF: "off",
ENFORCE: "enforce",
OBSERVE: "observe",
} as const;

export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];

export interface AccessRestrictions {
allowed_cidrs?: string[];
blocked_cidrs?: string[];
allowed_countries?: string[];
blocked_countries?: string[];
crowdsec_mode?: CrowdSecMode;
}

export interface ReverseProxyMeta {
Expand Down Expand Up @@ -102,6 +111,7 @@ export interface ReverseProxyDomain {
target_cluster?: string;
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
}

export enum ReverseProxyDomainType {
Expand Down Expand Up @@ -149,6 +159,7 @@ export interface ReverseProxyEvent {
bytes_upload: number;
bytes_download: number;
protocol?: EventProtocol;
metadata?: Record<string, string>;
}

export function isL4Event(event: ReverseProxyEvent): boolean {
Expand Down
98 changes: 71 additions & 27 deletions src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useReducer, useRef } from "react";
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import Button from "@components/Button";
Expand All @@ -18,7 +18,8 @@ import {
SelectOption,
} from "@components/select/SelectDropdown";
import { CountrySelector } from "@/components/ui/CountrySelector";
import { AccessRestrictions } from "@/interfaces/ReverseProxy";
import { AccessRestrictions, CrowdSecMode } from "@/interfaces/ReverseProxy";
import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation";

type AccessAction = "allow" | "block";
type AccessRuleType = "country" | "ip" | "cidr";
Expand Down Expand Up @@ -93,30 +94,41 @@ function rulesReducer(state: AccessRule[], action: RulesAction): AccessRule[] {
}
}

function pushCidrRules(
rules: AccessRule[],
values: string[] | undefined,
action: AccessAction,
) {
values?.forEach((v) => {
const isIp = v.includes(":") ? v.endsWith("/128") : v.endsWith("/32");
rules.push({
id: nextId(),
action,
type: isIp ? "ip" : "cidr",
value: isIp ? v.replace(/\/(32|128)$/, "") : v,
});
});
}

function restrictionsToRules(
restrictions: AccessRestrictions | undefined,
): AccessRule[] {
if (!restrictions) return [];
const rules: AccessRule[] = [];
restrictions.allowed_countries?.forEach((v) =>
rules.push({ id: nextId(), action: "allow", type: "country", value: v }),
);
pushCidrRules(rules, restrictions.blocked_cidrs, "block");
restrictions.blocked_countries?.forEach((v) =>
rules.push({ id: nextId(), action: "block", type: "country", value: v }),
);
restrictions.allowed_cidrs?.forEach((v) => {
const isIp = v.endsWith("/32");
rules.push({ id: nextId(), action: "allow", type: isIp ? "ip" : "cidr", value: isIp ? v.replace(/\/32$/, "") : v });
});
restrictions.blocked_cidrs?.forEach((v) => {
const isIp = v.endsWith("/32");
rules.push({ id: nextId(), action: "block", type: isIp ? "ip" : "cidr", value: isIp ? v.replace(/\/32$/, "") : v });
});
pushCidrRules(rules, restrictions.allowed_cidrs, "allow");
restrictions.allowed_countries?.forEach((v) =>
rules.push({ id: nextId(), action: "allow", type: "country", value: v }),
);
return rules;
}

function rulesToRestrictions(
rules: AccessRule[],
crowdsecMode?: CrowdSecMode,
): AccessRestrictions | undefined {
const allowed_countries: string[] = [];
const blocked_countries: string[] = [];
Expand All @@ -129,17 +141,23 @@ function rulesToRestrictions(
if (rule.action === "allow") allowed_countries.push(rule.value);
else blocked_countries.push(rule.value);
} else {
const value = rule.type === "ip" && !rule.value.includes("/") ? `${rule.value}/32` : rule.value;
const suffix = rule.value.includes(":") ? "/128" : "/32";
const value =
rule.type === "ip" && !rule.value.includes("/")
? `${rule.value}${suffix}`
: rule.value;
if (rule.action === "allow") allowed_cidrs.push(value);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else blocked_cidrs.push(value);
}
}

const hasCrowdSec = crowdsecMode != null && crowdsecMode !== CrowdSecMode.OFF;
const hasAny =
allowed_countries.length > 0 ||
blocked_countries.length > 0 ||
allowed_cidrs.length > 0 ||
blocked_cidrs.length > 0;
blocked_cidrs.length > 0 ||
hasCrowdSec;

if (!hasAny) return undefined;

Expand All @@ -148,13 +166,15 @@ function rulesToRestrictions(
...(blocked_countries.length > 0 && { blocked_countries }),
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
};
}

type Props = {
value: AccessRestrictions | undefined;
onChange: (value: AccessRestrictions | undefined) => void;
onValidationChange?: (hasErrors: boolean) => void;
supportsCrowdSec?: boolean;
};

function validateRule(rule: AccessRule): string {
Expand All @@ -172,13 +192,22 @@ function validateRule(rule: AccessRule): string {
return "";
}

export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationChange }: Props) => {
export const ReverseProxyAccessControlRules = ({
value,
onChange,
onValidationChange,
supportsCrowdSec,
}: Props) => {
const [rules, dispatch] = useReducer(
rulesReducer,
value,
restrictionsToRules,
);

const [crowdsecMode, setCrowdsecMode] = useState<CrowdSecMode>(
value?.crowdsec_mode ?? CrowdSecMode.OFF,
);

const errors = useMemo(
() => Object.fromEntries(rules.map((r) => [r.id, validateRule(r)])),
[rules],
Expand All @@ -196,15 +225,28 @@ export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationCh
onValidationChangeRef.current = onValidationChange;

useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules));
}, [rules]);
if (!supportsCrowdSec) {
setCrowdsecMode(CrowdSecMode.OFF);
}
}, [supportsCrowdSec]);

useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode));
}, [rules, crowdsecMode]);

useEffect(() => {
onValidationChangeRef.current?.(hasErrors);
}, [hasErrors]);

return (
<div className={"flex-col flex"}>
{supportsCrowdSec && (
<ReverseProxyCrowdSecIPReputation
value={crowdsecMode}
onChange={setCrowdsecMode}
/>
)}

<div>
<Label>Access Control Rules</Label>
<HelpText>
Expand Down Expand Up @@ -301,15 +343,17 @@ export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationCh
))}
</div>
)}
<Button
variant="dotted"
className="w-full"
size="sm"
onClick={() => dispatch({ type: "add" })}
>
<PlusIcon size={14} />
Add Rule
</Button>
<div className="flex gap-2">
<Button
variant="dotted"
className="flex-1"
size="sm"
onClick={() => dispatch({ type: "add" })}
>
<PlusIcon size={14} />
Add Rule
</Button>
</div>
</div>
);
};
109 changes: 109 additions & 0 deletions src/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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 { CrowdSecMode } from "@/interfaces/ReverseProxy";
import Image from "next/image";
import CrowdSecIconImage from "@/assets/integrations/crowdsec.png";

type Props = {
value: CrowdSecMode;
onChange: (value: CrowdSecMode) => void;
};

type CrowdSecOption = {
label: string;
description?: string;
icon: ReactNode;
};

const CROWDSEC_OPTIONS: Record<CrowdSecMode, CrowdSecOption> = {
[CrowdSecMode.OFF]: {
label: "Disabled",
icon: <PowerOffIcon size={14} />,
},
[CrowdSecMode.ENFORCE]: {
label: "Enforce",
description:
"Blocked IPs are denied immediately. If the bouncer is not yet synced, connections are denied (fail-closed).",
icon: <ShieldCheckIcon size={14} />,
},
[CrowdSecMode.OBSERVE]: {
label: "Observe",
description:
"Blocked IPs are logged but not denied. Use this to evaluate CrowdSec before enforcing.",
icon: <EyeIcon size={14} />,
},
};

export const ReverseProxyCrowdSecIPReputation = ({
value,
onChange,
}: Props) => {
const selected = CROWDSEC_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 IP Reputation</Label>
<HelpText>
Detect malicious IPs with CrowdSec.{" "}
<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 CrowdSecMode)}>
<SelectTrigger className="w-[260px]">
<div className="flex items-center gap-2 whitespace-nowrap">
{selected.icon}
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
{Object.entries(CROWDSEC_OPTIONS).map(([mode, config]) => (
<SelectItem
key={mode}
value={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>
);
};
1 change: 1 addition & 0 deletions src/modules/reverse-proxy/ReverseProxyModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ export default function ReverseProxyModal({
value={accessRestrictions}
onChange={setAccessRestrictions}
onValidationChange={setAccessControlHasErrors}
supportsCrowdSec={selectedDomain?.supports_crowdsec}
/>
</div>
</TabsContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
Mail,
Network,
RectangleEllipsis,
ShieldAlert,
ShieldOff,
Users,
} from "lucide-react";
import * as React from "react";
Expand Down Expand Up @@ -69,6 +71,26 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
icon: <GlobeOff size={12} />,
label: "Geo Unavailable",
};
case "crowdsec_ban":
return {
icon: <ShieldAlert size={12} />,
label: "CrowdSec Ban",
};
case "crowdsec_captcha":
return {
icon: <ShieldAlert size={12} />,
label: "CrowdSec Captcha",
};
case "crowdsec_throttle":
return {
icon: <ShieldAlert size={12} />,
label: "CrowdSec Throttle",
};
case "crowdsec_unavailable":
return {
icon: <ShieldOff size={12} />,
label: "CrowdSec Unavailable",
};
default:
return {
icon: null,
Expand Down
Loading
Loading