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
25 changed files with 720 additions and 1081 deletions

No files matched your search

@@ -1,144 +0,0 @@
/**
* Agent Network settings wire-shape spec.
*
* The management API signals "account not bootstrapped" differently by
* generation: current servers answer GET /agent-network/settings with the
* defaults object carrying an empty cluster/subdomain/endpoint, older ones
* with 200 + a JSON null body, and the oldest with a 404.
* useAgentNetworkSettings normalizes all three to the same null-settings
* signal, and this spec pins that: the providers page must render the
* connect-first endpoint placeholder for every unbootstrapped shape, and the
* endpoint badge once the account is bootstrapped. The settings route is
* mocked per test, so the assertions do not depend on the backend build or
* on account state left behind by other suites.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const EMPTY_STATE_TEXT =
"Connect your first provider to set up your agent network endpoint.";
const BOOTSTRAPPED_ENDPOINT = "violet.eu.proxy.netbird.io";
// The defaults object current servers return before the account is
// bootstrapped: values present, cluster/subdomain/endpoint empty, no
// timestamps (no row has been persisted yet).
const UNBOOTSTRAPPED_DEFAULTS = {
cluster: "",
subdomain: "",
endpoint: "",
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
};
const BOOTSTRAPPED_SETTINGS = {
cluster: "eu.proxy.netbird.io",
subdomain: "violet",
endpoint: BOOTSTRAPPED_ENDPOINT,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
async function mockSettingsResponse(
page: Page,
response: { status: number; body: string },
) {
await page.route("**/api/agent-network/settings", (route) => {
if (route.request().method() !== "GET") return route.continue();
return route.fulfill({
status: response.status,
contentType: "application/json",
body: response.body,
});
});
}
async function openProvidersPage(
browser: Browser,
settingsResponse: { status: number; body: string },
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
// Close the context on any setup failure — the caller only receives the
// close callback once setup succeeds.
try {
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await mockSettingsResponse(page, settingsResponse);
await loginToApp(page, "owner");
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
return { page, close: () => context.close() };
} catch (e) {
await context.close();
throw e;
}
}
const UNBOOTSTRAPPED_SHAPES: {
name: string;
response: { status: number; body: string };
}[] = [
{
name: "defaults object with empty endpoint (current servers)",
response: { status: 200, body: JSON.stringify(UNBOOTSTRAPPED_DEFAULTS) },
},
{
name: "200 with JSON null body (older servers)",
response: { status: 200, body: "null" },
},
{
name: "404 (oldest servers)",
response: {
status: 404,
body: JSON.stringify({ message: "settings not found", code: 404 }),
},
},
];
test.describe("Agent Network settings wire shapes @agent-network", () => {
for (const shape of UNBOOTSTRAPPED_SHAPES) {
test(`unbootstrapped account renders the empty state: ${shape.name}`, async ({
browser,
}) => {
const { page, close } = await openProvidersPage(browser, shape.response);
try {
await expect(page.getByText(EMPTY_STATE_TEXT)).toBeVisible();
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toHaveCount(0);
} finally {
await close();
}
});
}
test("bootstrapped account renders the endpoint instead of the empty state", async ({
browser,
}) => {
const { page, close } = await openProvidersPage(browser, {
status: 200,
body: JSON.stringify(BOOTSTRAPPED_SETTINGS),
});
try {
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toBeVisible();
await expect(page.getByText(EMPTY_STATE_TEXT)).toHaveCount(0);
} finally {
await close();
}
});
});
+4 -73
View File
@@ -72,19 +72,13 @@ function mockAccounts(page: Page, state: AccountState, delayMs: number) {
async function openOnboarding(
browser: Browser,
opts: {
source?: boolean;
account: AccountState;
delayMs?: number;
edition?: string;
expectOnboarding?: boolean;
},
opts: { source?: boolean; account: AccountState; delayMs?: number },
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([sourceKey, sourceValue, withSource, edition]) => {
([sourceKey, sourceValue, withSource]) => {
try {
window.localStorage.setItem("netbird-test-onboarding", "true");
if (withSource) {
@@ -93,32 +87,13 @@ async function openOnboarding(
sourceValue as string,
);
}
if (edition) {
window.localStorage.setItem("netbird-test-edition", edition as string);
}
} catch (e) {}
},
[
SIGNUP_SOURCE_KEY,
AGENT_NETWORK_SOURCE,
!!opts.source,
opts.edition ?? "",
] as const,
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE, !!opts.source] as const,
);
const page = await context.newPage();
mockAccounts(page, opts.account, opts.delayMs ?? 0);
// The mocked GET /accounts is what drives the onboarding decision, and the
// dashboard can resolve before it lands — a "no form opened" assertion would
// then pass vacuously. Wait for the rewritten response before returning.
const accountsLoaded = page.waitForResponse(
(resp) =>
/\/api\/accounts(\?|$)/.test(resp.url()) &&
resp.request().method() === "GET",
);
await loginToApp(page, "owner", {
expectOnboarding: opts.expectOnboarding ?? true,
});
await accountsLoaded;
await loginToApp(page, "owner", { expectOnboarding: true });
return { page, close: () => context.close() };
}
@@ -168,50 +143,6 @@ test.describe.serial("Onboarding form selection @onboarding", () => {
}
});
test("a self-hosted account with onboarding pending shows the regular flow at the intent step", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
edition: "oss",
account: { onboardingFlowPending: true },
});
try {
await expect(page.getByTestId(REGULAR_FORM)).toBeVisible();
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
// The signup survey relies on a JWT domain claim self-hosted IdPs don't
// emit, so the flow skips it and opens on the intent step. Scoped to the
// form: the dashboard behind the modal has its own "Get Started with
// NetBird" heading, and getByText matches case-insensitively.
await expect(
page
.getByTestId(REGULAR_FORM)
.getByRole("heading", { name: "Get started with NetBird" }),
).toBeVisible();
} finally {
await close();
}
});
test("a self-hosted account with only the signup form pending shows no onboarding", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
edition: "oss",
account: { signupFormPending: true },
expectOnboarding: false,
});
try {
// loginToApp resolved the dashboard, so the account state has been
// applied — neither flow should have opened.
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
} finally {
await close();
}
});
test("a slow backend never flashes the regular form for a netbird.ai signup", async ({
browser,
}) => {
-8
View File
@@ -128,14 +128,6 @@ async function openRowActions(
name: string,
) {
await clearScrollLock(page);
// A force-click can open a new action menu without the previous one having
// closed (scroll-lock artifacts suppress Radix's outside-click dismissal),
// leaving two menus open — the item lookup then hits a strict-mode violation.
// Dismiss any open menu and wait for it to be gone before opening the next.
if (await page.locator('[role="menu"]').count()) {
await page.keyboard.press("Escape");
await expect(page.locator('[role="menu"]')).toHaveCount(0);
}
await page
.locator("tr")
.filter({ hasText: name })
-122
View File
@@ -1,122 +0,0 @@
/**
* Linux tab of the setup modal — distro selector spec.
*
* The manual-install accordion offers per-distro instructions via a
* SelectDropdown (data-testid="linux-distro-select"). This validates that
* switching distros swaps the repository and install commands, that
* CLI-only distros render no desktop-app line, and that the copy button
* emits commands without comment lines. Runs against /install, which
* renders the setup modal without authentication.
*/
import { expect, test } from "@playwright/test";
async function openLinuxManualInstall(page: import("@playwright/test").Page) {
await page.goto("/install");
await page.getByTestId("setup-netbird-modal").waitFor();
await page.getByRole("tab", { name: /linux/i }).click();
await page.getByText("Install manually with a package manager").click();
await page.getByTestId("linux-distro-select").waitFor();
}
async function selectDistro(
page: import("@playwright/test").Page,
name: string,
) {
await page.getByTestId("linux-distro-select").click();
await page.getByRole("option", { name }).click();
}
test.describe("Setup modal Linux distro instructions", () => {
test("defaults to APT with repository, install and run steps", async ({
page,
}) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
await expect(modal.getByTestId("linux-distro-select")).toContainText(
"Debian / Ubuntu (APT)",
);
await expect(modal).toContainText(
"https://pkgs.netbird.io/debian stable main",
);
await expect(modal).toContainText("sudo apt-get install netbird");
await expect(modal).toContainText(
"sudo apt-get install netbird-ui libgtk-4-1 libwebkitgtk-6.0-4 xdg-utils",
);
// The run step stays present alongside the distro-specific steps
await expect(modal.getByText("netbird up")).toHaveCount(2);
});
test("switches repository and install commands per distro", async ({
page,
}) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
await selectDistro(page, "Fedora (DNF)");
await expect(modal).toContainText("/etc/yum.repos.d/netbird.repo");
await expect(modal).toContainText("sudo dnf install netbird");
await expect(modal).toContainText(
"sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils",
);
await expect(modal).not.toContainText("apt-get");
await expect(modal).not.toContainText("epel-release");
await selectDistro(page, "RHEL / AlmaLinux / Rocky (DNF)");
await expect(modal).toContainText("sudo dnf install epel-release -y");
await expect(modal).toContainText(
"sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils",
);
});
test("imports the repository GPG key on openSUSE", async ({ page }) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
await selectDistro(page, "openSUSE (Zypper)");
await expect(modal).toContainText(
"sudo zypper --non-interactive addrepo -f -g https://pkgs.netbird.io/yum/ netbird",
);
await expect(modal).toContainText(
"sudo zypper --gpg-auto-import-keys refresh netbird",
);
await expect(modal).toContainText("sudo zypper install netbird");
await expect(modal).toContainText(
"sudo zypper install netbird-ui libgtk-4-1 libwebkitgtk-6_0-4 xdg-utils",
);
// Zypper ignores gpgkey= in a .repo file, so that snippet must not appear
await expect(modal).not.toContainText("/etc/yum.repos.d/netbird.repo");
});
test("offers CLI only on Amazon Linux", async ({ page }) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
await selectDistro(page, "Amazon Linux (YUM)");
await expect(modal).toContainText("sudo yum install netbird");
await expect(modal).not.toContainText("netbird-ui");
await expect(modal).not.toContainText("for CLI only");
await expect(modal).toContainText("only the CLI is available");
});
test("copies install commands without comment lines", async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
// Copy buttons in DOM order: install script, run command, repository,
// install step, run command (accordion).
await modal.getByTestId("copy-to-clipboard").nth(3).click();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied, "clipboard should hold runnable commands only").toBe(
[
"sudo apt-get update",
"sudo apt-get install netbird",
"sudo apt-get install netbird-ui libgtk-4-1 libwebkitgtk-6.0-4 xdg-utils",
].join("\n"),
);
});
});
-8
View File
@@ -22,15 +22,7 @@ test.describe.serial("Team - Service Users @team", () => {
test("Should update role and manage access tokens", async ({ dashboardAsOwner: page }) => {
await page.locator("tr").getByText(regularUser).click();
await changeRoleTo(page, "Admin");
// Await the PUT so the role change is persisted before the next serial
// test asserts it — clicking save alone returns before the request lands.
const saveResponse = page.waitForResponse(
(resp) =>
resp.url().includes("/api/users/") && resp.request().method() === "PUT",
{ timeout: 30_000 },
);
await page.getByTestId("save-changes").click();
await saveResponse;
// Create and delete access token
const tokenName = generateRandomName("tkn_");
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+8 -24
View File
@@ -17,13 +17,6 @@ function formatVersion(version: string): string {
return version;
}
// Self-hosted builds can carry a numeric build suffix (e.g. "0.76.3-31256681241")
// that overflows the sidebar. Show the semver only and keep the rest for the
// tooltip. Pre-release labels like "-rc.1" are left intact so they stay visible.
function formatShortVersion(version: string): string {
return formatVersion(version).replace(/^(v?\d+(?:\.\d+)*)-\d+$/, "$1");
}
function compareVersions(current: string, latest: string): boolean {
// Returns true if latest is newer than current
if (!current || !latest) return false;
@@ -105,15 +98,9 @@ const NavigationVersionInfoContent = () => {
<div className="flex flex-col gap-1 text-nb-gray-400">
<FullTooltip
content={
<div className="text-xs flex flex-col gap-1">
<span>
Installed:{" "}
{formatVersion(versionInfo.management_current_version)}
</span>
<span>
Latest: {formatVersion(versionInfo.management_available_version)}
</span>
</div>
<span className="text-xs">
Latest: {formatVersion(versionInfo.management_available_version)}
</span>
}
side="top"
className="w-full"
@@ -121,18 +108,15 @@ const NavigationVersionInfoContent = () => {
<div className="flex items-center justify-between w-full cursor-default">
<span>Management</span>
<span className="text-nb-gray-300 font-medium">
{formatShortVersion(versionInfo.management_current_version)}
{formatVersion(versionInfo.management_current_version)}
</span>
</div>
</FullTooltip>
<FullTooltip
content={
<div className="text-xs flex flex-col gap-1">
<span>Installed: {formatVersion(dashboardVersion)}</span>
<span>
Latest: {formatVersion(versionInfo.dashboard_available_version)}
</span>
</div>
<span className="text-xs">
Latest: {formatVersion(versionInfo.dashboard_available_version)}
</span>
}
side="top"
className="w-full"
@@ -140,7 +124,7 @@ const NavigationVersionInfoContent = () => {
<div className="flex items-center justify-between w-full cursor-default">
<span>Dashboard</span>
<span className="text-nb-gray-300 font-medium">
{formatShortVersion(dashboardVersion)}
{formatVersion(dashboardVersion)}
</span>
</div>
</FullTooltip>
+2 -2
View File
@@ -57,9 +57,9 @@ export default function DataTableHeader({
{children}
{sorting &&
(column.getIsSorted() === "desc" ? (
<IconSortDescending size={16} />
) : (
<IconSortAscending size={16} />
) : (
<IconSortDescending size={16} />
))}
</div>
</FullTooltip>
+11
View File
@@ -33,12 +33,21 @@ export const 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 {
allowed_cidrs?: string[];
blocked_cidrs?: string[];
allowed_countries?: string[];
blocked_countries?: string[];
crowdsec_mode?: CrowdSecMode;
appsec_mode?: AppSecMode;
}
export interface ReverseProxyMeta {
@@ -122,6 +131,7 @@ export interface ReverseProxyDomain {
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
supports_appsec?: boolean;
supports_private?: boolean;
}
@@ -188,6 +198,7 @@ export interface ReverseProxyCluster {
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
supports_appsec?: boolean;
// 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.
// Lets the dashboard distinguish per-peer / private clusters from centralised
File diff suppressed because it is too large. Load diff
@@ -27,13 +27,6 @@ export type APIProviderModel = {
id: string;
input_per_1k: number;
output_per_1k: number;
// Optional cache rates. Omitted on the wire = inherit NetBird's
// default rate for this model (the backend folds it in at synthesis
// time); explicit 0 = no discount (bucket bills at the input rate).
// Never coerce undefined to 0 — the two mean different things.
cached_input_per_1k?: number;
cache_read_per_1k?: number;
cache_creation_per_1k?: number;
};
export type APIProvider = {
@@ -125,10 +118,8 @@ export type APIAgentNetworkSettings = {
enable_prompt_collection: boolean;
redact_pii: boolean;
access_log_retention_days?: number;
// Absent until the account is bootstrapped — pre-bootstrap the backend
// returns the defaults without a persisted row to date.
created_at?: string;
updated_at?: string;
created_at: string;
updated_at: string;
};
// APIAgentNetworkSettingsRequest matches the PUT /agent-network/settings
@@ -165,9 +156,6 @@ function fromAPI(p: APIProvider): AIProvider {
id: m.id,
inputPer1k: m.input_per_1k,
outputPer1k: m.output_per_1k,
cachedInputPer1k: m.cached_input_per_1k,
cacheReadPer1k: m.cache_read_per_1k,
cacheCreationPer1k: m.cache_creation_per_1k,
}));
return {
id: p.id,
@@ -203,16 +191,10 @@ function fromAPI(p: APIProvider): AIProvider {
}
function toAPIModels(models: ProviderModel[]): APIProviderModel[] {
// undefined cache rates stay undefined so JSON.stringify omits the
// key: an omitted rate inherits NetBird's default, an explicit 0
// disables the discount. Coercing here would change billing.
return models.map((m) => ({
id: m.id,
input_per_1k: m.inputPer1k,
output_per_1k: m.outputPer1k,
cached_input_per_1k: m.cachedInputPer1k,
cache_read_per_1k: m.cacheReadPer1k,
cache_creation_per_1k: m.cacheCreationPer1k,
}));
}
@@ -523,12 +505,10 @@ export function useAIProviders() {
}
// useAgentNetworkSettings fetches the account-level agent-network settings.
// Returns null until the account is bootstrapped (first provider create, or
// a settings update carrying a cluster). Backends signal the unbootstrapped
// state differently by age: current ones respond 200 with the defaults and
// an empty cluster/subdomain/endpoint, older ones 200 + JSON null, and the
// oldest 404 — tolerated via ignoreError so old deploys don't surface a
// spurious error in the empty state. All three normalize to null here.
// Returns null until the first provider is created — newer backends respond
// 200 + JSON null while no settings row exists; older backends respond 404,
// which we still tolerate via ignoreError so older deploys don't surface
// a spurious error in the empty state.
export function useAgentNetworkSettings() {
const { enabled: agentNetworkEnabled } = useAgentNetworkMode();
const { data, error, isLoading, mutate } =
@@ -538,14 +518,11 @@ export function useAgentNetworkSettings() {
true,
agentNetworkEnabled,
);
const notFound = !!error && (error as { code?: number }).code === 404;
// SWR keeps the previous data alongside the error (keepPreviousData), so a
// later 404 must not expose the stale settings; other transient errors keep
// them, which is what keepPreviousData is for.
const settings = useMemo<AgentNetworkSettings | null>(
() => (data && data.endpoint && !notFound ? settingsFromAPI(data) : null),
[data, notFound],
() => (data ? settingsFromAPI(data) : null),
[data],
);
const notFound = !!error && (error as { code?: number }).code === 404;
return {
settings,
isLoading: isLoading && !notFound,
@@ -683,7 +660,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
updates.metadataDisabled ?? existing.metadata_disabled,
models: updates.models
? toAPIModels(updates.models)
: (existing.models ?? []),
: existing.models ?? [],
enabled: updates.enabled ?? existing.enabled,
};
try {
@@ -768,7 +745,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
guardrail_ids: updates.guardrailIds ?? existing.guardrail_ids ?? [],
limits: updates.limits
? policyLimitsToAPI(updates.limits)
: (existing.limits ?? policyLimitsToAPI(EMPTY_POLICY_LIMITS)),
: existing.limits ?? policyLimitsToAPI(EMPTY_POLICY_LIMITS),
};
try {
await policiesApi.put(merged, `/${id}`);
+109 -157
View File
@@ -445,13 +445,12 @@ export default function AgentAccessLogTable({
</DataTableHeader>
),
// Same Reason cell as the flat view (deny reason, or the authorising
// policy link). Prefer an allowed request so a session that succeeded
// shows the authorising policy rather than an incidental deny reason;
// only surface a deny reason when every request was denied.
// policy link). A session is one policy decision in practice; surface a
// denied request when present, otherwise the first entry.
cell: ({ row }) => {
const entries = row.original.entries;
const representative =
entries.find((e) => e.decision === "allow") ?? entries[0];
entries.find((e) => e.decision === "deny") ?? entries[0];
return representative ? (
<ReasonCell entry={representative} />
) : (
@@ -997,10 +996,13 @@ function ProviderCell({
);
}
// TokenBreakdown is the hover content shared by the flat Tokens column and the
// session's per-request rows: one line per token bucket plus a total. Buckets
// default to 0 so it renders for denied requests that carry partial counts.
function TokenBreakdown({ entry }: { entry: AIAccessLogEntry }) {
function TokensCell({ entry }: { entry: AIAccessLogEntry }) {
if (
(entry.inputTokens === undefined || entry.inputTokens === 0) &&
(entry.outputTokens === undefined || entry.outputTokens === 0)
) {
return <EmptyRow />;
}
const cacheRead = entry.cachedInputTokens ?? 0;
const cacheWrite = entry.cacheCreationTokens ?? 0;
// Anthropic-shape cache buckets are additive to input tokens, so they count toward the total.
@@ -1010,66 +1012,54 @@ function TokenBreakdown({ entry }: { entry: AIAccessLogEntry }) {
cacheRead +
cacheWrite;
return (
<div className={"text-xs flex flex-col gap-1"}>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>
{(entry.inputTokens ?? 0).toLocaleString()}
</span>
<span className={"text-nb-gray-400"}>input</span>
</div>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>
{(entry.outputTokens ?? 0).toLocaleString()}
</span>
<span className={"text-nb-gray-400"}>output</span>
</div>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>{cacheRead.toLocaleString()}</span>
<span className={"text-nb-gray-400"}>cache read</span>
</div>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>{cacheWrite.toLocaleString()}</span>
<span className={"text-nb-gray-400"}>cache write</span>
</div>
<div
className={
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
}
>
<span className={"font-medium text-nb-gray-200"}>
{total.toLocaleString()}
</span>
<span>total</span>
</div>
</div>
);
}
function TokensCell({ entry }: { entry: AIAccessLogEntry }) {
// Cache-only requests carry no input/output but real cache read/write tokens,
// so weigh all four buckets — matching TokenBreakdown's total.
if (
(entry.inputTokens ?? 0) === 0 &&
(entry.outputTokens ?? 0) === 0 &&
(entry.cachedInputTokens ?? 0) === 0 &&
(entry.cacheCreationTokens ?? 0) === 0
) {
return <EmptyRow />;
}
return (
<FullTooltip content={<TokenBreakdown entry={entry} />}>
<FullTooltip
content={
<div className={"text-xs flex flex-col gap-1"}>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>
{entry.inputTokens.toLocaleString()}
</span>
<span className={"text-nb-gray-400"}>input</span>
</div>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>
{entry.outputTokens.toLocaleString()}
</span>
<span className={"text-nb-gray-400"}>output</span>
</div>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>{cacheRead.toLocaleString()}</span>
<span className={"text-nb-gray-400"}>cache read</span>
</div>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>{cacheWrite.toLocaleString()}</span>
<span className={"text-nb-gray-400"}>cache write</span>
</div>
<div
className={
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
}
>
<span className={"font-medium text-nb-gray-200"}>
{total.toLocaleString()}
</span>
<span>total</span>
</div>
</div>
}
>
<div
className={"flex flex-col text-xs gap-1 text-nb-gray-300 font-medium"}
>
<div className={"flex gap-2 items-center whitespace-nowrap"}>
<ArrowUpIcon size={15} className={"text-sky-400"} />
<span className={"sr-only"}>Input:</span>
{(entry.inputTokens ?? 0).toLocaleString()}
{entry.inputTokens.toLocaleString()}
</div>
<div className={"flex gap-2 items-center whitespace-nowrap"}>
<ArrowDownIcon size={15} className={"text-netbird"} />
<span className={"sr-only"}>Output:</span>
{(entry.outputTokens ?? 0).toLocaleString()}
{entry.outputTokens.toLocaleString()}
</div>
</div>
</FullTooltip>
@@ -1086,89 +1076,32 @@ function CostRow({ amount, label }: { amount: number; label: string }) {
);
}
type CostFields = {
costUsd: number;
cacheCostUsd?: number;
inputCostUsd?: number;
cachedInputCostUsd?: number;
cacheCreationCostUsd?: number;
outputCostUsd?: number;
};
// hasCostBreakdown reports whether a request carries enough cost detail to be
// worth a hover: a cache split or a per-bucket breakdown. Shared so the flat
// Cost cell and the session rows attach the tooltip on the same condition.
function hasCostBreakdown(f: CostFields): boolean {
const cache = f.cacheCostUsd ?? 0;
const perBucket = f.inputCostUsd !== undefined || f.outputCostUsd !== undefined;
return cache > 0 || perBucket;
}
// CostBreakdown is the hover content shared by the flat Cost column and the
// session's per-request rows.
// CostCell renders the metered USD cost with a hover breakdown of the buckets
// it was billed from.
//
// Servers that send the per-bucket breakdown get one line per bucket the
// provider bills separately (input / output / cache read / cache write). Older
// servers send only the two aggregates, so the hover falls back to the coarse
// "input + output" vs "cache" split derivable from those — the two shapes are
// distinguished by whether inputCostUsd is defined, not by whether it is zero.
function CostBreakdown({
function CostCell({
costUsd,
cacheCostUsd,
inputCostUsd,
cachedInputCostUsd,
cacheCreationCostUsd,
outputCostUsd,
}: CostFields) {
}: {
costUsd: number;
cacheCostUsd?: number;
inputCostUsd?: number;
cachedInputCostUsd?: number;
cacheCreationCostUsd?: number;
outputCostUsd?: number;
}) {
const cache = cacheCostUsd ?? 0;
const hasBreakdown =
inputCostUsd !== undefined || outputCostUsd !== undefined;
const cacheRead = cachedInputCostUsd ?? 0;
const cacheWrite = cacheCreationCostUsd ?? 0;
return (
<div className={"text-xs flex flex-col gap-1 font-mono"}>
{hasBreakdown ? (
<>
{/* All four buckets, including zeros: a zero cache-read line is
information (the request missed the cache), and a fixed set of
rows keeps the hover comparable between requests. */}
<CostRow amount={inputCostUsd ?? 0} label={"input"} />
<CostRow amount={outputCostUsd ?? 0} label={"output"} />
<CostRow amount={cacheRead} label={"cache read"} />
<CostRow amount={cacheWrite} label={"cache write"} />
</>
) : (
<>
<CostRow amount={costUsd - cache} label={"input + output"} />
<CostRow amount={cache} label={"cache"} />
</>
)}
<div
className={
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
}
>
<span className={"font-medium text-nb-gray-200"}>
${costUsd.toFixed(4)}
</span>
<span className={"font-sans"}>total</span>
</div>
</div>
);
}
// CostCell renders the metered USD cost with a hover breakdown of the buckets
// it was billed from (see CostBreakdown).
function CostCell(fields: CostFields) {
const {
costUsd,
cacheCostUsd,
inputCostUsd,
cachedInputCostUsd,
cacheCreationCostUsd,
outputCostUsd,
} = fields;
const cache = cacheCostUsd ?? 0;
// Nothing was metered: the request never reached a provider (denied before
// routing), or ran on a model the proxy deliberately doesn't price. A dash
@@ -1194,10 +1127,45 @@ function CostCell(fields: CostFields) {
</span>
);
// Nothing to break out: no cache spend and no per-bucket split to show.
if (!hasCostBreakdown(fields)) return display;
if (cache <= 0 && !hasBreakdown) return display;
const cacheRead = cachedInputCostUsd ?? 0;
const cacheWrite = cacheCreationCostUsd ?? 0;
return (
<FullTooltip content={<CostBreakdown {...fields} />}>{display}</FullTooltip>
<FullTooltip
content={
<div className={"text-xs flex flex-col gap-1 font-mono"}>
{hasBreakdown ? (
<>
{/* All four buckets, including zeros: a zero cache-read line is
information (the request missed the cache), and a fixed set of
rows keeps the hover comparable between requests. */}
<CostRow amount={inputCostUsd ?? 0} label={"input"} />
<CostRow amount={outputCostUsd ?? 0} label={"output"} />
<CostRow amount={cacheRead} label={"cache read"} />
<CostRow amount={cacheWrite} label={"cache write"} />
</>
) : (
<>
<CostRow amount={costUsd - cache} label={"input + output"} />
<CostRow amount={cache} label={"cache"} />
</>
)}
<div
className={
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
}
>
<span className={"font-medium text-nb-gray-200"}>
${costUsd.toFixed(4)}
</span>
<span className={"font-sans"}>total</span>
</div>
</div>
}
>
{display}
</FullTooltip>
);
}
@@ -1355,13 +1323,7 @@ function SessionEntriesRow({ session }: { session: AIAccessLogSession }) {
>
{[...session.entries].reverse().map((entry) => {
const isOpen = open.includes(entry.id);
// Sum all four buckets so cache-only requests count and the figure
// matches TokenBreakdown's total.
const total =
(entry.inputTokens ?? 0) +
(entry.outputTokens ?? 0) +
(entry.cachedInputTokens ?? 0) +
(entry.cacheCreationTokens ?? 0);
const total = (entry.inputTokens ?? 0) + (entry.outputTokens ?? 0);
const isError = entry.decision === "deny" || entry.status >= 400;
return (
<div key={entry.id}>
@@ -1434,30 +1396,20 @@ function SessionEntriesRow({ session }: { session: AIAccessLogSession }) {
>
{entry.model || "—"}
</code>
<FullTooltip
disabled={total === 0}
content={<TokenBreakdown entry={entry} />}
<span
className={
"text-xs text-nb-gray-400 font-mono whitespace-nowrap tabular-nums w-[110px] text-right shrink-0"
}
>
<span
className={
"text-xs text-nb-gray-400 font-mono whitespace-nowrap tabular-nums w-[110px] text-right shrink-0"
}
>
{total.toLocaleString()} tokens
</span>
</FullTooltip>
<FullTooltip
disabled={!hasCostBreakdown(entry)}
content={<CostBreakdown {...entry} />}
{total.toLocaleString()} tokens
</span>
<span
className={
"text-xs text-nb-gray-400 font-mono whitespace-nowrap tabular-nums w-[64px] text-right shrink-0"
}
>
<span
className={
"text-xs text-nb-gray-400 font-mono whitespace-nowrap tabular-nums w-[64px] text-right shrink-0"
}
>
${entry.costUsd.toFixed(4)}
</span>
</FullTooltip>
${entry.costUsd.toFixed(4)}
</span>
</button>
{isOpen && (
<div className={"border-t border-nb-gray-800/70"}>
@@ -43,17 +43,6 @@ export type ProviderModel = {
id: string;
inputPer1k: number;
outputPer1k: number;
// Optional prompt-cache rates (USD per 1k tokens). undefined means
// "inherit NetBird's default rate for this model" — the backend folds
// the default in at synthesis time; an explicit 0 means "no discount,
// bill this cache bucket at the input rate". Keep undefined distinct
// from 0 when round-tripping.
// OpenAI shape: cached prompt tokens (a subset of input tokens).
cachedInputPer1k?: number;
// Anthropic shape: the two additive cache buckets (read ≈0.1x input,
// creation ≈1.25x input).
cacheReadPer1k?: number;
cacheCreationPer1k?: number;
};
export type AIProvider = {
@@ -8,12 +8,6 @@ export type CatalogModel = {
label: string;
input_per_1k: number;
output_per_1k: number;
// Default cache rates, present only when the model has one. Used to
// prefill the model row so the operator sees (and can override) the
// rate NetBird would bill with.
cached_input_per_1k?: number;
cache_read_per_1k?: number;
cache_creation_per_1k?: number;
context_window: number;
};
@@ -48,13 +42,6 @@ export type CatalogProvider = {
// can choose between the always-on x-bf-lh- log family and the
// declared x-bf-dim- telemetry family.
identity_injection?: CatalogIdentityInjection;
// pricing_surfaces names the cost-meter surfaces this provider's
// traffic is metered under ("openai", "anthropic", "bedrock"). The
// modal uses it to decide which cache-rate fields apply: "openai" →
// cached input (subset discount); "anthropic"/"bedrock" → cache read
// + cache write (additive buckets). Absent for gateway/custom entries
// — show every cache field for those.
pricing_surfaces?: string[];
models: CatalogModel[];
};
+14
View File
@@ -30,6 +30,7 @@ import { OnboardingExplainDefaultPolicy } from "@/modules/onboarding/p2p/Onboard
import { OnboardingFirstDevice } from "@/modules/onboarding/p2p/OnboardingFirstDevice";
import { OnboardingSecondDevice } from "@/modules/onboarding/p2p/OnboardingSecondDevice";
import { OnboardingTestP2P } from "@/modules/onboarding/p2p/OnboardingTestP2P";
import { OnboardingDemoCall } from "@/modules/onboarding/OnboardingDemoCall";
export interface OnboardingState {
intent: Intent;
@@ -110,6 +111,7 @@ export const Onboarding = ({
const [onboarding, dispatch] = useReducer(onboardingReducer, initial);
const { step, intent } = onboarding;
const [demoCallModal, setDemoCallModal] = useState(false);
const [resource, setResource] = useState<NetworkResource>();
const [firstRoutingPeer, setFirstRoutingPeer] = useState<Peer>();
@@ -393,10 +395,22 @@ export const Onboarding = ({
payload: new Date().toISOString(),
});
}
const companySize = fields?.find(
(f) => f.name === "planned_users",
);
if (Number(companySize?.value) >= 50) {
setDemoCallModal(true);
}
}}
/>
)}
<OnboardingDemoCall
open={demoCallModal}
onOpenChange={setDemoCallModal}
/>
{step === 2 && (
<OnboardingIntent
useCases={useCases}
@@ -0,0 +1,261 @@
import Button from "@components/Button";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import { CalendarClockIcon, CalendarIcon, Check } from "lucide-react";
import Image from "next/image";
import * as React from "react";
import Avatar from "@/assets/avatars/jack.jpeg";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { useExperiment } from "@/cloud/cloud-hooks/useExperiment";
import { useAnalytics } from "@/contexts/AnalyticsProvider";
import { useAccount } from "@/modules/account/useAccount";
import { useLoggedInUser } from "@/contexts/UsersProvider";
type Props = {
open: boolean;
onOpenChange: React.Dispatch<React.SetStateAction<boolean>>;
};
const NAME = "Jack Carter";
const BOOK_A_DEMO_URL =
"https://meetings-eu1.hubspot.com/jack81/selfserve45-dashboard";
export const OnboardingDemoCall = ({ open, onOpenChange }: Props) => {
const { trackEventV2 } = useAnalytics();
const account = useAccount();
const { loggedInUser } = useLoggedInUser();
const [variant, variantKey] = useExperiment("onboarding-call", {
v1: {
title: "Book a Technical Overview (Not a Sales Call)",
desc: "You’ll meet with a solutions engineer who will walk through how NetBird works, answer your implementation questions - no slides, no hard sell.",
features: [
"Live walkthrough of setup and architecture",
"Implementation of use case, for your stack",
"Best practices and general overview",
],
cta: "Book Now",
cancel: "No Thanks",
},
v2: {
title: "Talk to our Solutions Engineer",
desc: (
<>
Get a 30-min technical overview. We’ll go over your specific use-case
and answer any technical questions you might have. <br /> We’re
offering this as a technical onboard support for you. <br /> This is
NOT a sales call.
</>
),
features: [],
cta: "Book Now",
cancel: "No Thanks",
},
v3: {
title: "Book a Technical Overview (Not a Sales Call)",
desc: (
<>
Get a 30-min technical overview. We’ll go over your specific use-case
and answer any technical questions you might have. <br /> We’re
offering this as a technical onboard support for you. <br /> This is
NOT a sales call.
</>
),
features: [],
cta: "Book Now",
cancel: "No Thanks",
},
v4: {
title: "Book a Technical Overview",
desc: "You’ll meet with a solutions engineer who will walk through how NetBird works, answer your implementation questions - no slides, no hard sell.",
features: [
"Live walkthrough of setup and architecture",
"Implementation of use case, for your stack",
"Best practices and general overview",
],
cta: "Book Now",
cancel: "No Thanks",
},
v5: {
title: "Talk to our Solutions Engineer",
desc: (
<>
Get a 30-min technical overview. We’ll go over your specific use-case
and answer any technical questions you might have. <br /> We’re
offering this as a technical onboard support for you.
</>
),
features: [],
cta: "Book Now",
cancel: "No Thanks",
},
v6: {
title: "Book a Technical Overview",
desc: (
<>
Get a 30-min technical overview. We’ll go over your specific use-case
and answer any technical questions you might have. <br /> We’re
offering this as a technical onboard support for you.
</>
),
features: [],
cta: "Book Now",
cancel: "No Thanks",
},
});
const openBookADemo = () => {
if (typeof window !== "undefined") {
trackEventV2(
"Onboarding Call Experiment",
variantKey,
account?.id,
loggedInUser?.id,
);
window.open(BOOK_A_DEMO_URL, "_blank");
onOpenChange(false);
}
};
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent
maxWidthClass={"max-w-lg relative !z-[70]"}
showClose={false}
>
<div className={"flex flex-col gap-4 overflow-hidden relative -top-3"}>
<div
className={
"flex items-center justify-center absolute bottom-0 w-full z-20"
}
>
<Image
src={Avatar}
alt={NAME}
height={50}
width={50}
className={"rounded-full"}
/>
</div>
<span
className={
"absolute inset-0 w-full h-full bg-gradient-to-b from-transparent to-nb-gray-950 z-10"
}
></span>
<div className={"flex gap-8 relative z-0 opacity-10 grayscale h-4"}>
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
</div>
<div
className={
"flex gap-8 -left-8 relative z-0 opacity-10 grayscale h-4"
}
>
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
</div>
<div className={"flex gap-4 relative z-0 opacity-10 grayscale h-4"}>
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
</div>
</div>
<div
className={
"flex items-center justify-center flex-col px-8 gap-3 mt-1"
}
>
<div className={"text-xl font-medium text-center"}>
{variant?.title}
</div>
<div className={"text-sm text-nb-gray-300 text-center"}>
{variant?.desc}
</div>
{variant?.features?.length > 1 && (
<ul className="flex flex-col gap-3.5 mt-4 mb-3 w-full">
{variant?.features.map((feature, index) => (
<li
className="flex items-center gap-3 text-sm text-nb-gray-200"
key={index}
>
<Check size={16} className={"text-netbird shrink-0"} />
{feature}
</li>
))}
</ul>
)}
</div>
<ModalFooter separator={false} className={"gap-x-2"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
{variant?.cancel}
</Button>
</ModalClose>
<Button
className={"w-full"}
variant={"primary"}
onClick={openBookADemo}
>
<CalendarIcon size={15} className={"shrink-0"} />
{variant?.cta}
</Button>
</ModalFooter>
<div
className={
"text-center z-0 mt-3 text-sm text-nb-gray-300 flex items-center justify-center gap-2 font-normal"
}
>
<CalendarClockIcon size={12} />
<div>
The call usually takes around
<span className={"font-medium"}> 30 minutes</span>
</div>
</div>
</ModalContent>
</Modal>
);
};
@@ -120,10 +120,7 @@ export const OnboardingProvider = ({
// deciding, so a slow mode fetch can't briefly show the regular form to an
// account that turns out to be Agent Network-only via config.
if (agentNetworkModeLoading) return false;
// The regular flow shows on both cloud and self-hosted, but the signup
// survey relies on a JWT domain claim self-hosted IdPs don't emit, so it
// only counts toward showing (and is only rendered) on cloud — self-hosted
// starts directly at the intent step.
if (!isNetBirdCloud()) return false;
const isSignupFormPending = isNetBirdCloud()
? !!account?.onboarding?.signup_form_pending
: false;
@@ -19,8 +19,13 @@ import {
SelectOption,
} from "@components/select/SelectDropdown";
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 { ReverseProxyAppSecInspection } from "@/modules/reverse-proxy/ReverseProxyAppSecInspection";
type AccessAction = "allow" | "block";
type AccessRuleType = "country" | "ip" | "cidr";
@@ -121,6 +126,7 @@ function restrictionsToRules(
function rulesToRestrictions(
rules: AccessRule[],
crowdsecMode?: CrowdSecMode,
appsecMode?: AppSecMode,
): AccessRestrictions | undefined {
const allowed_countries: string[] = [];
const blocked_countries: string[] = [];
@@ -144,12 +150,14 @@ function rulesToRestrictions(
}
const hasCrowdSec = crowdsecMode != null && crowdsecMode !== CrowdSecMode.OFF;
const hasAppSec = appsecMode != null && appsecMode !== AppSecMode.OFF;
const hasAny =
allowed_countries.length > 0 ||
blocked_countries.length > 0 ||
allowed_cidrs.length > 0 ||
blocked_cidrs.length > 0 ||
hasCrowdSec;
hasCrowdSec ||
hasAppSec;
if (!hasAny) return undefined;
@@ -159,6 +167,7 @@ function rulesToRestrictions(
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
...(hasAppSec && { appsec_mode: appsecMode }),
};
}
@@ -167,6 +176,7 @@ type Props = {
onChange: (value: AccessRestrictions | undefined) => void;
onValidationChange?: (hasErrors: boolean) => void;
supportsCrowdSec?: boolean;
supportsAppSec?: boolean;
};
function validateRule(rule: AccessRule): string {
@@ -193,6 +203,7 @@ export const ReverseProxyAccessControlRules = ({
onChange,
onValidationChange,
supportsCrowdSec,
supportsAppSec,
}: Props) => {
const [rules, dispatch] = useReducer(
rulesReducer,
@@ -200,6 +211,10 @@ export const ReverseProxyAccessControlRules = ({
restrictionsToRules,
);
const [appsecMode, setAppsecMode] = useState<AppSecMode>(
value?.appsec_mode ?? AppSecMode.OFF,
);
const [crowdsecMode, setCrowdsecMode] = useState<CrowdSecMode>(
value?.crowdsec_mode ?? CrowdSecMode.OFF,
);
@@ -227,8 +242,14 @@ export const ReverseProxyAccessControlRules = ({
}, [supportsCrowdSec]);
useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode));
}, [rules, crowdsecMode]);
if (!supportsAppSec) {
setAppsecMode(AppSecMode.OFF);
}
}, [supportsAppSec]);
useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode, appsecMode));
}, [rules, crowdsecMode, appsecMode]);
useEffect(() => {
onValidationChangeRef.current?.(hasErrors);
@@ -242,6 +263,12 @@ export const ReverseProxyAccessControlRules = ({
onChange={setCrowdsecMode}
/>
)}
{supportsAppSec && (
<ReverseProxyAppSecInspection
value={appsecMode}
onChange={setAppsecMode}
/>
)}
<div>
<Label>Access Control Rules</Label>
<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}
onValidationChange={setAccessControlHasErrors}
supportsCrowdSec={selectedDomain?.supports_crowdsec}
supportsAppSec={!isL4Mode && selectedDomain?.supports_appsec}
/>
</div>
</TabsContent>
Loaded 20 of 25 files, more files were not shown because too many files have changed in this diff. Show more