Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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
107 changes: 80 additions & 27 deletions src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
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";
import { Input } from "@components/Input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import cidr from "ip-cidr";
import {
FlagIcon,
MinusCircleIcon,
NetworkIcon,
PlusIcon,
ShieldAlertIcon,
ShieldCheckIcon,
ShieldXIcon,
WorkflowIcon,
Expand All @@ -18,7 +26,7 @@ import {
SelectOption,
} from "@components/select/SelectDropdown";
import { CountrySelector } from "@/components/ui/CountrySelector";
import { AccessRestrictions } from "@/interfaces/ReverseProxy";
import { AccessRestrictions, CrowdSecMode } from "@/interfaces/ReverseProxy";

type AccessAction = "allow" | "block";
type AccessRuleType = "country" | "ip" | "cidr";
Expand Down Expand Up @@ -93,30 +101,32 @@ 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 +139,20 @@ 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 +161,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 +187,17 @@ 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 +215,47 @@ 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 && (
<div className="mb-6">
<Label>
<ShieldAlertIcon size={14} />
CrowdSec IP Reputation
</Label>
<HelpText>
Block or monitor connections from IPs flagged by CrowdSec. Enforce
blocks immediately, observe logs without blocking.
</HelpText>
<Select
value={crowdsecMode}
onValueChange={(v) => setCrowdsecMode(v as CrowdSecMode)}
>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={CrowdSecMode.OFF}>Off</SelectItem>
<SelectItem value={CrowdSecMode.ENFORCE}>Enforce</SelectItem>
<SelectItem value={CrowdSecMode.OBSERVE}>Observe</SelectItem>
</SelectContent>
</Select>
</div>
)}

<div>
<Label>Access Control Rules</Label>
<HelpText>
Expand Down Expand Up @@ -301,15 +352,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>
);
};
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
50 changes: 50 additions & 0 deletions src/modules/reverse-proxy/events/ReverseProxyEventsReasonCell.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 (
<FullTooltip
side="top"
interactive
delayDuration={250}
skipDelayDuration={100}
disabled={metaEntries.length === 0}
contentClassName="p-0"
content={
<div className="text-xs flex flex-col">
{metaEntries.map(([key, val]) => (
<ListItem
key={key}
icon={<Info size={14} />}
label={key.replaceAll("_", " ")}
value={<span className="text-nb-gray-200">{val}</span>}
/>
))}
</div>
}
>
<div className="px-3 py-2">
<Badge variant="gray" className="gap-1.5">
<ShieldAlert size={12} className="text-yellow-500" />
CrowdSec Observe: {verdictLabel}
</Badge>
</div>
</FullTooltip>
);
}

return (
<span className="text-nb-gray-300 text-[0.82rem] py-2 text-left">
{event.reason || "-"}
Expand Down
Loading
Loading