diff --git a/src/assets/integrations/crowdsec.png b/src/assets/integrations/crowdsec.png new file mode 100644 index 00000000..6f003d3a Binary files /dev/null and b/src/assets/integrations/crowdsec.png differ diff --git a/src/interfaces/ReverseProxy.ts b/src/interfaces/ReverseProxy.ts index 3bff4dd4..192dd705 100644 --- a/src/interfaces/ReverseProxy.ts +++ b/src/interfaces/ReverseProxy.ts @@ -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 { @@ -102,6 +111,7 @@ export interface ReverseProxyDomain { target_cluster?: string; supports_custom_ports?: boolean; require_subdomain?: boolean; + supports_crowdsec?: boolean; } export enum ReverseProxyDomainType { @@ -149,6 +159,7 @@ export interface ReverseProxyEvent { bytes_upload: number; bytes_download: number; protocol?: EventProtocol; + metadata?: Record; } export function isL4Event(event: ReverseProxyEvent): boolean { diff --git a/src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx b/src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx index 0ced8faa..7dd9fda0 100644 --- a/src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx +++ b/src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx @@ -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"; @@ -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"; @@ -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[] = []; @@ -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); 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; @@ -148,6 +166,7 @@ function rulesToRestrictions( ...(blocked_countries.length > 0 && { blocked_countries }), ...(allowed_cidrs.length > 0 && { allowed_cidrs }), ...(blocked_cidrs.length > 0 && { blocked_cidrs }), + ...(hasCrowdSec && { crowdsec_mode: crowdsecMode }), }; } @@ -155,6 +174,7 @@ type Props = { value: AccessRestrictions | undefined; onChange: (value: AccessRestrictions | undefined) => void; onValidationChange?: (hasErrors: boolean) => void; + supportsCrowdSec?: boolean; }; function validateRule(rule: AccessRule): string { @@ -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( + value?.crowdsec_mode ?? CrowdSecMode.OFF, + ); + const errors = useMemo( () => Object.fromEntries(rules.map((r) => [r.id, validateRule(r)])), [rules], @@ -196,8 +225,14 @@ 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); @@ -205,6 +240,13 @@ export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationCh return (
+ {supportsCrowdSec && ( + + )} +
@@ -301,15 +343,17 @@ export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationCh ))}
)} - +
+ +
); }; diff --git a/src/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation.tsx b/src/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation.tsx new file mode 100644 index 00000000..18ce9976 --- /dev/null +++ b/src/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation.tsx @@ -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.OFF]: { + label: "Disabled", + icon: , + }, + [CrowdSecMode.ENFORCE]: { + label: "Enforce", + description: + "Blocked IPs are denied immediately. If the bouncer is not yet synced, connections are denied (fail-closed).", + icon: , + }, + [CrowdSecMode.OBSERVE]: { + label: "Observe", + description: + "Blocked IPs are logged but not denied. Use this to evaluate CrowdSec before enforcing.", + icon: , + }, +}; + +export const ReverseProxyCrowdSecIPReputation = ({ + value, + onChange, +}: Props) => { + const selected = CROWDSEC_OPTIONS[value]; + + return ( +
+
+
+ {"CrowdSec"} +
+
+ + + Detect malicious IPs with CrowdSec.{" "} + Enforce to block them or{" "} + Observe to only log without + blocking. + +
+
+ + +
+ ); +}; diff --git a/src/modules/reverse-proxy/ReverseProxyModal.tsx b/src/modules/reverse-proxy/ReverseProxyModal.tsx index 0ed64be1..8dc89185 100644 --- a/src/modules/reverse-proxy/ReverseProxyModal.tsx +++ b/src/modules/reverse-proxy/ReverseProxyModal.tsx @@ -590,6 +590,7 @@ export default function ReverseProxyModal({ value={accessRestrictions} onChange={setAccessRestrictions} onValidationChange={setAccessControlHasErrors} + supportsCrowdSec={selectedDomain?.supports_crowdsec} /> diff --git a/src/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell.tsx b/src/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell.tsx index 798ca9f6..f87c171f 100644 --- a/src/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell.tsx +++ b/src/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell.tsx @@ -7,6 +7,8 @@ import { Mail, Network, RectangleEllipsis, + ShieldAlert, + ShieldOff, Users, } from "lucide-react"; import * as React from "react"; @@ -69,6 +71,26 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => { icon: , label: "Geo Unavailable", }; + case "crowdsec_ban": + return { + icon: , + label: "CrowdSec Ban", + }; + case "crowdsec_captcha": + return { + icon: , + label: "CrowdSec Captcha", + }; + case "crowdsec_throttle": + return { + icon: , + label: "CrowdSec Throttle", + }; + case "crowdsec_unavailable": + return { + icon: , + label: "CrowdSec Unavailable", + }; default: return { icon: null, diff --git a/src/modules/reverse-proxy/events/ReverseProxyEventsReasonCell.tsx b/src/modules/reverse-proxy/events/ReverseProxyEventsReasonCell.tsx index c3540fe8..18a32fe4 100644 --- a/src/modules/reverse-proxy/events/ReverseProxyEventsReasonCell.tsx +++ b/src/modules/reverse-proxy/events/ReverseProxyEventsReasonCell.tsx @@ -1,11 +1,61 @@ +import Badge from "@components/Badge"; +import FullTooltip from "@components/FullTooltip"; +import { ListItem } from "@components/ListItem"; +import { Info, ShieldAlert } from "lucide-react"; import * as React from "react"; import { ReverseProxyEvent } from "@/interfaces/ReverseProxy"; +const VERDICT_LABELS: Record = { + crowdsec_ban: "Ban", + crowdsec_captcha: "Captcha", + crowdsec_throttle: "Throttle", +}; + type Props = { event: ReverseProxyEvent; }; export const ReverseProxyEventsReasonCell = ({ event }: Props) => { + const metadata = event.metadata; + const verdict = metadata?.crowdsec_verdict; + + if (verdict && !event.auth_method_used?.startsWith("crowdsec_")) { + const verdictLabel = VERDICT_LABELS[verdict] ?? verdict; + const metaEntries = Object.entries(metadata!).filter( + ([k]) => k !== "crowdsec_verdict", + ); + + return ( + + {metaEntries.map(([key, val]) => ( + } + label={key.replaceAll("_", " ")} + value={{val}} + /> + ))} + + } + > +
+ + + CrowdSec Observe: {verdictLabel} + +
+
+ ); + } + return ( {event.reason || "-"} diff --git a/src/modules/reverse-proxy/table/ReverseProxyAccessControlCell.tsx b/src/modules/reverse-proxy/table/ReverseProxyAccessControlCell.tsx index 823b468f..e2af02ef 100644 --- a/src/modules/reverse-proxy/table/ReverseProxyAccessControlCell.tsx +++ b/src/modules/reverse-proxy/table/ReverseProxyAccessControlCell.tsx @@ -10,6 +10,7 @@ import { LucideIcon, NetworkIcon, Settings, + ShieldAlert, ShieldCheck, ShieldOff, WorkflowIcon, @@ -19,7 +20,7 @@ import { useMemo } from "react"; import { useCountries } from "@/contexts/CountryProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useReverseProxies } from "@/contexts/ReverseProxiesProvider"; -import { ReverseProxy } from "@/interfaces/ReverseProxy"; +import { CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy"; type RuleEntry = { key: string; @@ -37,17 +38,27 @@ export default function ReverseProxyAccessControlCell({ reverseProxy, }: Readonly) { const { permission } = usePermissions(); - const { openModal } = useReverseProxies(); + const { openModal, domains } = useReverseProxies(); const { countries } = useCountries(); const canConfigure = !!permission?.services?.update; const restrictions = reverseProxy.access_restrictions; + const supportsCrowdSec = domains?.find( + (d) => d.domain === reverseProxy.proxy_cluster, + )?.supports_crowdsec; + + const hasCrowdSec = + supportsCrowdSec && + restrictions?.crowdsec_mode != null && + restrictions.crowdsec_mode !== CrowdSecMode.OFF; + const ruleCount = (restrictions?.allowed_cidrs?.length ?? 0) + (restrictions?.blocked_cidrs?.length ?? 0) + (restrictions?.allowed_countries?.length ?? 0) + - (restrictions?.blocked_countries?.length ?? 0); + (restrictions?.blocked_countries?.length ?? 0) + + (hasCrowdSec ? 1 : 0); const rulesBadge = ruleCount > 0 ? ( @@ -92,21 +103,23 @@ export default function ReverseProxyAccessControlCell({ }); } + const isHostCidr = (c: string) => + c.includes(":") ? c.endsWith("/128") : c.endsWith("/32"); const allowedIps = - restrictions?.allowed_cidrs?.filter((c) => c.endsWith("/32")) ?? []; + restrictions?.allowed_cidrs?.filter(isHostCidr) ?? []; const allowedCidrs = - restrictions?.allowed_cidrs?.filter((c) => !c.endsWith("/32")) ?? []; + restrictions?.allowed_cidrs?.filter((c) => !isHostCidr(c)) ?? []; const blockedIps = - restrictions?.blocked_cidrs?.filter((c) => c.endsWith("/32")) ?? []; + restrictions?.blocked_cidrs?.filter(isHostCidr) ?? []; const blockedCidrs = - restrictions?.blocked_cidrs?.filter((c) => !c.endsWith("/32")) ?? []; + restrictions?.blocked_cidrs?.filter((c) => !isHostCidr(c)) ?? []; if (allowedIps.length) { entries.push({ key: "allowed-ips", label: allowedIps.length === 1 ? "Allowed IP" : "Allowed IPs", Icon: WorkflowIcon, - value: allowedIps.map((c) => c.replace(/\/32$/, "")).join(", "), + value: allowedIps.map((c) => c.replace(/\/(32|128)$/, "")).join(", "), }); } @@ -124,7 +137,7 @@ export default function ReverseProxyAccessControlCell({ key: "blocked-ips", label: blockedIps.length === 1 ? "Blocked IP" : "Blocked IPs", Icon: WorkflowIcon, - value: blockedIps.map((c) => c.replace(/\/32$/, "")).join(", "), + value: blockedIps.map((c) => c.replace(/\/(32|128)$/, "")).join(", "), blocked: true, }); } @@ -139,8 +152,20 @@ export default function ReverseProxyAccessControlCell({ }); } + if (hasCrowdSec) { + entries.push({ + key: "crowdsec", + label: "CrowdSec", + Icon: ShieldAlert, + value: + restrictions?.crowdsec_mode === CrowdSecMode.ENFORCE + ? "Enforce" + : "Observe", + }); + } + return entries; - }, [restrictions, countries]); + }, [restrictions, countries, hasCrowdSec]); const showRulesHover = ruleGroups.length > 0;