Compare commits

..
Author SHA1 Message Date
riccardom b786c3395a Drop the self-hosted caveat from the Debug Bundle Upload help text
The client no longer keeps the bundle on the peer when a self-hosted deployment
publishes no destination: netbirdio/netbird now falls back to the upload service
NetBird runs for every deployment, so the field's help text described behaviour
that is not there.
2026-09-11 12:44:57 +02:00
riccardom 3c009ddc0d Add the debug-bundle upload destination to the Clients settings
A debug bundle carries peer logs, routes, DNS and firewall state, and until now
every client path that uploads one without a human picking a destination sent it
to the service NetBird runs, with no way for a self-hosted operator to point it
elsewhere. netbirdio/netbird publishes the destination to peers from the account
settings; this exposes that setting.

Empty keeps the current behaviour for cloud accounts and, for a self-hosted
deployment, makes the peers keep the bundle locally instead. The URL is
validated as https-with-host on the client too, matching what the API accepts:
the peer fetches an upload URL from this endpoint and then PUTs the bundle to
whatever comes back, so a plaintext hop would expose both.
2026-09-09 09:38:55 +02:00
21 changed files with 94 additions and 566 deletions

No files matched your search

-1
View File
@@ -1,7 +1,6 @@
# Ignore zitadel environment
.env
Caddyfile
agentgateway-stub.Caddyfile
management.json
turnserver.conf
zitadel.env
+1 -1
View File
@@ -19,7 +19,7 @@ check_docker_compose() {
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
$DOCKER_COMPOSE_COMMAND down --volumes
rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -rf proxy-certs proxy-certs-no-ports
rm -f ../../.test-config.json ../playwright.env.json
rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json
+1 -35
View File
@@ -532,7 +532,7 @@ initEnvironment() {
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
echo "You can use the following commands:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
echo " rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1
fi
@@ -540,7 +540,6 @@ initEnvironment() {
echo Rendering initial files...
renderDockerCompose > docker-compose.yml
renderCaddyfile > Caddyfile
renderAgentGatewayStub > agentgateway-stub.Caddyfile
renderZitadelEnv > zitadel.env
echo "" > turnserver.conf
echo "" > management.json
@@ -613,30 +612,6 @@ initEnvironment() {
echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env
}
# Saving an Agent Network provider makes management call the upstream and
# refuses the save unless it answers a model listing, so a provider spec needs
# a reachable OpenAI-compatible endpoint. agentgateway is self-hosted and has
# no public one; this stub stands in for it, inside the compose network where
# management can reach it and without depending on a vendor being up.
renderAgentGatewayStub() {
cat <<'EOF'
{
admin off
auto_https off
}
:8088 {
handle /v1/models {
header Content-Type application/json
respond `{"object":"list","data":[{"id":"agentgateway-e2e-model","object":"model","owned_by":"netbird-e2e"}]}` 200
}
handle {
respond 404
}
}
EOF
}
renderCaddyfile() {
cat <<EOF
{
@@ -843,15 +818,6 @@ services:
volumes:
- netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
# Stand-in upstream for the Agent Network provider specs: answers the model
# listing management probes before it accepts a provider save. Reachable
# only from inside the compose network, at http://agentgateway-stub:8088.
agentgateway-stub:
image: caddy
restart: unless-stopped
networks: [ netbird ]
volumes:
- ./agentgateway-stub.Caddyfile:/etc/caddy/Caddyfile
# Management
management:
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
-25
View File
@@ -24,31 +24,6 @@ export async function openControlCenter(page: Page, tab?: FlowView) {
await dismissBlockingOverlays(page);
}
/**
* Opens the groups view already showing `groupName`.
*
* The view picks its own group on first render (getFirstGroup: the highest
* peers_count that is a policy source), and that ranking runs over every group
* in the account — including ones a spec on the other worker seeded moments
* ago. Deleting the spec's own fixtures first is not enough to pin it, so
* select the group by name and let the view rebuild around it.
*/
export async function openGroupView(page: Page, groupName: string) {
await openControlCenter(page, "groups");
const selector = canvasNode(page, "select-group-node");
await expect(selector).toBeVisible();
// Unforced on purpose: the opening fitView animates the canvas, and the
// actionability check is what waits for the node to stop moving.
await selector.click();
await page.getByTestId("select-dropdown-search").fill(groupName);
await page
.locator('[role="option"]')
.filter({ hasText: groupName })
.first()
.click({ force: true });
await expect(selector).toContainText(groupName);
}
export async function switchFlowView(page: Page, view: FlowView) {
await dismissBlockingOverlays(page);
await page.getByTestId(`cc-flow-${view}`).click({ force: true });
@@ -59,12 +59,7 @@ test.describe
.getByTestId("agent-network-provider-option-agentgateway")
.click({ force: true });
// Saving a provider makes management call the upstream, and it refuses
// the save with a 422 unless the upstream answers a model listing — so a
// made-up host cannot be used here. agentgateway is self-hosted and has
// no public endpoint to point at, so create-test-env.sh runs a stub that
// answers that probe inside the compose network.
const upstreamURL = "http://agentgateway-stub:8088";
const upstreamURL = "https://agentgateway.e2e.example";
await page
.getByTestId("agent-network-provider-upstream-url")
.fill(upstreamURL);
@@ -364,17 +364,13 @@ test.describe.serial("Control Center Draft Matrix @control-center", () => {
}) => {
const group = await place(page, "group", 0.6, 0.5);
await expectChangeCount(page, 1);
// React Flow ignores delete keys while an input is focused, and the panel's
// search takes focus on click, so blur it before pressing Backspace.
await group.click();
await expect(group).toHaveClass(/selected/);
// React Flow ignores delete keys while an input is focused, and the panel
// the click opens takes focus into its search — from an effect, about
// 100ms later. Blurring before that lands leaves the input to claim focus
// afterwards and swallow the Backspace, so wait for the focus, then drop
// it.
const panelSearch = page.locator("#cc-group-panel input").first();
await expect(panelSearch).toBeFocused();
await panelSearch.blur();
await expect(panelSearch).not.toBeFocused();
await page.evaluate(
() => (document.activeElement as HTMLElement | null)?.blur(),
);
await page.keyboard.press("Backspace");
await expect(canvasNode(page, "group-new-")).toHaveCount(0);
await expectChangeCount(page, 0);
@@ -19,7 +19,6 @@ import {
dismissBlockingOverlays,
enterDraft,
openControlCenter,
openGroupView,
resetDraftState,
reviewButton,
} from "../helpers/control-center";
@@ -38,6 +37,7 @@ test.describe
await deleteGroupsByPrefix(page, PREFIX);
});
// Clean-slated so the draft group view's auto-select is deterministic.
async function seedPolicyGroupView(page: Page) {
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -49,9 +49,7 @@ test.describe
src.id,
dst.id,
);
// Selected by name: the view's own pick ranks every group in the account,
// so a peer-bearing group from another spec on the other worker wins it.
await openGroupView(page, src.name);
await openControlCenter(page, "groups");
await enterDraft(page);
const policyNode = canvasNode(page, `policy-${policy.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
+14 -7
View File
@@ -18,7 +18,6 @@ import {
clickContextMenuItem,
dismissBlockingOverlays,
openControlCenter,
openGroupView,
resetDraftState,
switchFlowView,
} from "../helpers/control-center";
@@ -48,6 +47,8 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
});
async function seedPolicyAndOpenGroupView(page: Page, enabled = true) {
// The group view auto-selects a group that HAS a policy, so stale policies
// would make the rendered group non-deterministic.
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -63,10 +64,15 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
const policyNode = canvasNode(page, `policy-${policy.id}`);
// Selecting src by name rather than trusting the view's own pick: the
// auto-select ranks every group in the account, so a peer-bearing group
// another spec seeded on the other worker outranks this one.
await openGroupView(page, src.name);
// A stale SWR read or a build/fit race can fail to place the node.
for (let attempt = 0; attempt < 3; attempt++) {
await openControlCenter(page, "groups");
const shown = await policyNode
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (shown) break;
}
await expect(policyNode).toBeVisible({ timeout: 10_000 });
return { src, dst, policy, policyNode };
}
@@ -506,6 +512,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
test("Should save group resource membership in live mode (correct payload)", async ({
dashboardAsOwner: page,
}) => {
// Clean slate so the group view deterministically lands on our group.
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -522,7 +529,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
[],
);
await openGroupView(page, src.name);
await openControlCenter(page, "groups");
const groupNode = canvasNode(page, `group-${dst.id}`);
await expect(groupNode).toBeVisible({ timeout: 15_000 });
@@ -603,7 +610,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
await createPolicy(page, generateRandomName(PREFIX), src.id, d2.id);
await openGroupView(page, src.name);
await openControlCenter(page, "groups");
const policyNode = canvasNode(page, `policy-${p1.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
await expect(
+1
View File
@@ -35,6 +35,7 @@ export interface Account {
local_mfa_enabled?: boolean;
ipv6_enabled_groups?: string[];
network_range_v6?: string;
debug_bundle_upload_url?: string;
agent_network_only?: boolean;
dashboard_features?: AccountDashboardFeatures;
};
-7
View File
@@ -17,14 +17,7 @@ export interface Workload {
// Parameters for bundle job
export interface BundleJobParameters {
anonymize: boolean;
// Anonymization level applied when anonymize is enabled. "default" keeps
// internal IP ranges readable; "strict" also anonymizes them. Omitted or
// empty resolves to the default on the peer.
anonymize_level?: "default" | "strict";
bundle_for: boolean;
bundle_for_time: number;
log_file_count: number;
// Upload service URL the peer requests an upload URL from. Empty selects the
// default upload server.
upload_url?: string;
}
-5
View File
@@ -11,7 +11,6 @@ export interface PostureCheck {
geo_location_check?: GeoLocationCheck;
peer_network_range_check?: PeerNetworkRangeCheck;
process_check?: ProcessCheck;
certificate_check?: CertificateCheck;
};
policies?: Policy[];
active?: boolean;
@@ -66,10 +65,6 @@ export interface Process {
windows_path?: string;
}
export interface CertificateCheck {
ca_certificates: string[];
}
export const windowsKernelVersions: SelectOption[] = [
{ value: "10.0", label: "Windows 10" },
{ value: "10.0.2", label: "Windows 11" },
@@ -2,19 +2,19 @@ import { Edge, Node } from "@xyflow/react";
import { sortBy } from "lodash";
import { Group } from "@/interfaces/Group";
import { Policy } from "@/interfaces/Policy";
import { useControlCenterData } from "@/modules/control-center/hooks/useControlCenterData";
import {
addEdge,
addNode,
addEdge,
DEFAULT_LAYOUT_CONFIG,
} from "@/modules/control-center/utils/graph-builder";
import { withFreshGroupCounts } from "@/modules/control-center/utils/helpers";
import { applyD3HierarchicalLayout } from "@/modules/control-center/utils/layouts";
import {
addAgentNetworkProviderNodes,
useAgentNetworkOverlay,
} from "./agent-network-overlay";
import { addDestinationResourceNodes, ViewResult } from "./types";
import { useControlCenterData } from "@/modules/control-center/hooks/useControlCenterData";
import { withFreshGroupCounts } from "@/modules/control-center/utils/helpers";
export function usePeerView() {
const { policies, peers, networks, networkResources, groups, isDataReady } =
@@ -39,7 +39,6 @@ export function usePeerView() {
(policiesOverride ?? policies!).filter((p) => {
const rule = p.rules?.[0];
if (!rule) return false;
if (rule.sourceResource?.id === peerId) return true;
const sources = rule.sources as Group[];
return sources?.some((d) => peerGroups?.some((pg) => pg.id === d.id));
}),
@@ -81,6 +80,7 @@ export function usePeerView() {
type: "smart",
data: { enabled, policy },
});
});
addDestinationResourceNodes(
+2 -76
View File
@@ -4,7 +4,6 @@ import {
FileText,
PlusCircle,
Shield,
UploadCloud,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
@@ -20,13 +19,6 @@ import {
} from "@/components/modal/Modal";
import ModalHeader from "@/components/modal/ModalHeader";
import { notify } from "@/components/Notification";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/Select";
import Separator from "@/components/Separator";
import { Workload } from "@/interfaces/Job";
import { useApiCall } from "@/utils/api";
@@ -44,15 +36,10 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
const [bundleForTime, setBundleForTime] = useState<string>("");
const [logFileCount, setLogFileCount] = useState<string>("10");
const [anonymize, setAnonymize] = useState<boolean>(false);
const [anonymizeLevel, setAnonymizeLevel] = useState<"default" | "strict">(
"default",
);
const [uploadUrl, setUploadUrl] = useState<string>("");
const isValid = useMemo(() => {
let validBundleFor = true;
let validLogFileCount = true;
let validUploadUrl = true;
const logFileCountNumber = Number(logFileCount);
const bundleForTimeNumber = Number(bundleForTime);
@@ -63,18 +50,8 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
validLogFileCount = logFileCountNumber >= 1 && logFileCountNumber <= 1000;
const trimmedUploadUrl = uploadUrl.trim();
if (trimmedUploadUrl) {
try {
const parsed = new URL(trimmedUploadUrl);
validUploadUrl = parsed.protocol === "https:" && parsed.host !== "";
} catch {
validUploadUrl = false;
}
}
return validLogFileCount && validBundleFor && validUploadUrl;
}, [bundleForTime, logFileCount, uploadUrl]);
return validLogFileCount && validBundleFor;
}, [bundleForTime, logFileCount]);
const createDebugJob = async () => {
notify({
@@ -87,13 +64,11 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
type: "bundle",
parameters: {
anonymize,
anonymize_level: anonymize ? anonymizeLevel : undefined,
bundle_for: bundleForTimeEnabled,
bundle_for_time: bundleForTimeEnabled
? Number(bundleForTime)
: undefined,
log_file_count: logFileCount ? Number(logFileCount) : 10,
upload_url: uploadUrl.trim() ? uploadUrl.trim() : undefined,
},
},
})
@@ -197,55 +172,6 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
}
helpText="Remove sensitive information (IP addresses, domains etc.) before creating the debug bundle."
/>
{/* Anonymization Level */}
{anonymize && (
<div className="flex justify-between gap-6">
<div className={"max-w-[300px]"}>
<Label>Anonymization Level</Label>
<HelpText>
Default keeps internal (private) IP ranges readable; Strict also
anonymizes private, CGNAT and link-local addresses.
</HelpText>
</div>
<Select
value={anonymizeLevel}
onValueChange={(v) => setAnonymizeLevel(v as "default" | "strict")}
>
<SelectTrigger className="w-[220px]">
<div className="flex items-center gap-3">
<Shield size={15} className="text-nb-gray-300 shrink-0" />
<SelectValue placeholder="Select level..." />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="strict">Strict</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Upload URL */}
<div className="flex justify-between gap-6">
<div className={"max-w-[300px]"}>
<Label>Upload URL (optional)</Label>
<HelpText>
Service the peer requests an upload URL from. Leave empty to use
the default upload server. Must be an https URL.
</HelpText>
</div>
<Input
type="text"
placeholder={"https://upload.debug.netbird.io"}
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
maxWidthClass="w-[220px]"
customPrefix={<UploadCloud size={16} className="text-nb-gray-300" />}
/>
</div>
</div>
<ModalFooter className="items-center">
@@ -1,212 +0,0 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Label } from "@components/Label";
import { ModalClose, ModalFooter } from "@components/modal/Modal";
import Paragraph from "@components/Paragraph";
import { Textarea } from "@components/Textarea";
import { uniqueId } from "lodash";
import {
ExternalLinkIcon,
FileKey2Icon,
MinusCircleIcon,
PlusCircle,
} from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { CertificateCheck } from "@/interfaces/PostureCheck";
import {
isValidPEMCertificate,
useCertificateFingerprints,
} from "@/modules/posture-checks/helper/CertificateHelper";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
type Props = {
value?: CertificateCheck;
onChange: (value: CertificateCheck | undefined) => void;
disabled?: boolean;
};
export const PostureCheckCertificate = ({
value,
onChange,
disabled,
}: Props) => {
const [open, setOpen] = useState(false);
return (
<PostureCheckCard
open={open}
setOpen={setOpen}
key={open ? 1 : 0}
active={value?.ca_certificates && value.ca_certificates.length > 0}
title={"Certificate"}
description={
"Restrict access to peers holding a certificate issued by your own certificate authority."
}
icon={<FileKey2Icon size={18} />}
iconClass={"bg-gradient-to-tr from-purple-500 to-purple-400"}
modalWidthClass={"max-w-2xl"}
onReset={() => onChange(undefined)}
>
<CheckContent
value={value}
onChange={(v) => {
onChange(v);
setOpen(false);
}}
disabled={disabled}
/>
</PostureCheckCard>
);
};
type CACertificate = {
id: string;
pem: string;
};
const newCertificate = (pem = ""): CACertificate => ({
id: uniqueId("ca-certificate"),
pem,
});
const rowsFor = (pem: string) =>
Math.min(Math.max(pem.split("\n").length + 1, 6), 32);
const CheckContent = ({ value, onChange, disabled }: Props) => {
const [certificates, setCertificates] = useState<CACertificate[]>(
value?.ca_certificates?.length
? value.ca_certificates.map((pem) => newCertificate(pem))
: [newCertificate()],
);
const pems = useMemo(() => certificates.map((c) => c.pem), [certificates]);
const fingerprints = useCertificateFingerprints(pems);
const errors = useMemo(
() =>
certificates.map((c) =>
c.pem.trim() !== "" && !isValidPEMCertificate(c.pem)
? "Please paste a valid PEM encoded certificate"
: "",
),
[certificates],
);
const hasErrorsOrIsEmpty =
certificates.length === 0 ||
certificates.some((c) => c.pem.trim() === "") ||
errors.some((e) => e !== "");
const updateCertificate = (id: string, pem: string) => {
setCertificates(certificates.map((c) => (c.id === id ? { ...c, pem } : c)));
};
const removeCertificate = (id: string) => {
setCertificates(certificates.filter((c) => c.id !== id));
};
return (
<>
<div className={"flex flex-col px-8 gap-2 pb-6"}>
<div className={"flex justify-between items-start gap-10 mt-2"}>
<div>
<Label>CA Certificates</Label>
<HelpText className={""}>
Paste the PEM encoded public certificate of the root or
intermediate CA that issues your device certificates. Peers will
only be allowed to connect if they hold a certificate issued by
one of these CAs and prove possession of its private key.
</HelpText>
</div>
</div>
{certificates.length > 0 && (
<div className={"mb-2 flex flex-col gap-4 w-full"}>
{certificates.map((c, index) => {
return (
<div key={c.id} className={"flex gap-2 items-start min-w-0"}>
<div className={"w-full flex flex-col gap-1.5 min-w-0"}>
<Textarea
value={c.pem}
rows={rowsFor(c.pem)}
placeholder={
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"
}
error={errors[index]}
className={
"w-full font-mono text-xs leading-5 overflow-auto"
}
onChange={(e) => updateCertificate(c.id, e.target.value)}
disabled={disabled}
/>
{fingerprints[index] && (
<span
className={
"text-xs text-nb-gray-400 font-mono break-all"
}
>
SHA-256 {fingerprints[index]}
</span>
)}
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={() => removeCertificate(c.id)}
disabled={disabled}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
})}
</div>
)}
<Button
variant={"dotted"}
size={"sm"}
onClick={() => setCertificates([...certificates, newCertificate()])}
className={"mt-1"}
disabled={disabled}
>
<PlusCircle size={16} />
Add CA Certificate
</Button>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/manage-posture-checks#certificate-check"
}
target={"_blank"}
>
Certificate Check
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={hasErrorsOrIsEmpty || disabled}
onClick={() =>
onChange({
ca_certificates: certificates.map((c) => c.pem.trim()),
})
}
>
Save
</Button>
</div>
</ModalFooter>
</>
);
};
@@ -1,73 +0,0 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { ScrollArea } from "@components/ScrollArea";
import { FileKey2Icon } from "lucide-react";
import * as React from "react";
import { CertificateCheck } from "@/interfaces/PostureCheck";
import { useCertificateFingerprints } from "@/modules/posture-checks/helper/CertificateHelper";
type Props = {
check?: CertificateCheck;
children?: React.ReactNode;
};
export const CertificateTooltip = ({ check, children }: Props) => {
const fingerprints = useCertificateFingerprints(check?.ca_certificates ?? []);
return check ? (
<FullTooltip
className={"w-full min-w-0"}
interactive={true}
contentClassName={"p-0"}
content={
<div
className={
"text-neutral-300 text-sm max-w-xs flex flex-col gap-1 min-w-0"
}
>
<div className={"px-4 pt-3"}>
<span>
<span className={"text-green-500 font-semibold"}>Allow only</span>{" "}
peers holding a certificate issued by one of the following CAs
</span>
</div>
<ScrollArea
className={
"max-h-[275px] overflow-y-auto flex flex-col px-4 min-w-0"
}
>
<div className={"flex flex-col gap-2 mt-1 text-xs mb-3.5 min-w-0"}>
{check.ca_certificates.map((_, index) => {
const fingerprint = fingerprints[index];
return (
<Badge
key={index}
variant={"gray"}
useHover={false}
className={"justify-start font-medium text-xs min-w-0"}
>
<span className={"mr-1.5"}>
<FileKey2Icon size={12} />
</span>
<span
className={"truncate inline-block font-mono"}
title={fingerprint}
>
{fingerprint
? `SHA-256 ${fingerprint}`
: `CA certificate ${index + 1}`}
</span>
</Badge>
);
})}
</div>
</ScrollArea>
</div>
}
>
{children}
</FullTooltip>
) : (
children
);
};
@@ -1,54 +0,0 @@
import { useEffect, useState } from "react";
const pemCertificateBlock =
/-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/g;
const base64 = /^[A-Za-z0-9+/]+={0,2}$/;
// Returns the base64 bodies of all CERTIFICATE blocks, or undefined when the input is
// not exclusively made of well-formed blocks.
export const pemCertificateBodies = (pem: string): string[] | undefined => {
const bodies: string[] = [];
const rest = pem.replace(pemCertificateBlock, (_, body: string) => {
bodies.push(body.replace(/\s+/g, ""));
return "";
});
if (bodies.length === 0 || rest.trim() !== "") return undefined;
const wellFormed = bodies.every(
(b) => b.length > 0 && b.length % 4 === 0 && base64.test(b),
);
return wellFormed ? bodies : undefined;
};
export const isValidPEMCertificate = (pem: string) =>
pemCertificateBodies(pem) !== undefined;
// SHA-256 fingerprint of the first certificate in the PEM, as colon separated hex.
export const certificateFingerprint = async (
pem: string,
): Promise<string | undefined> => {
const bodies = pemCertificateBodies(pem);
if (!bodies || typeof crypto === "undefined" || !crypto.subtle) return;
const der = Uint8Array.from(atob(bodies[0]), (c) => c.charCodeAt(0));
const digest = await crypto.subtle.digest("SHA-256", der);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join(":");
};
export const useCertificateFingerprints = (pems: string[]) => {
const [fingerprints, setFingerprints] = useState<(string | undefined)[]>([]);
const key = JSON.stringify(pems);
useEffect(() => {
let cancelled = false;
const list: string[] = JSON.parse(key);
Promise.all(list.map(certificateFingerprint)).then((result) => {
if (!cancelled) setFingerprints(result);
});
return () => {
cancelled = true;
};
}, [key]);
return fingerprints;
};
@@ -14,7 +14,6 @@ import { ExternalLinkIcon, LayoutList, ShieldCheck, Text } from "lucide-react";
import React, { useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { PostureCheckCertificate } from "@/modules/posture-checks/checks/PostureCheckCertificate";
import { PostureCheckGeoLocation } from "@/modules/posture-checks/checks/PostureCheckGeoLocation";
import { PostureCheckNetBirdVersion } from "@/modules/posture-checks/checks/PostureCheckNetBirdVersion";
import { PostureCheckOperatingSystem } from "@/modules/posture-checks/checks/PostureCheckOperatingSystem";
@@ -57,8 +56,7 @@ export default function PostureCheckModal({
!!check?.checks?.geo_location_check ||
!!check?.checks?.os_version_check ||
!!check?.checks?.peer_network_range_check ||
!!check?.checks.process_check ||
!!check?.checks?.certificate_check;
!!check?.checks.process_check;
const canCreate =
!isEmpty(check?.name) &&
isAtLeastOneCheckEnabled &&
@@ -167,18 +165,6 @@ export default function PostureCheckModal({
!permission.policies.create || !permission.policies.update
}
/>
<PostureCheckCertificate
value={check?.checks?.certificate_check}
onChange={(v) =>
setCheck({
type: "certificate_check",
payload: v,
})
}
disabled={
!permission.policies.create || !permission.policies.update
}
/>
</>
</TabsContent>
<TabsContent value={"general"} className={"pb-8 px-8"}>
@@ -1,15 +1,8 @@
import { cn } from "@utils/helpers";
import {
Disc3Icon,
FileKey2Icon,
FlagIcon,
NetworkIcon,
ServerCogIcon,
} from "lucide-react";
import { Disc3Icon, FlagIcon, NetworkIcon, ServerCogIcon } from "lucide-react";
import * as React from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { CertificateTooltip } from "@/modules/posture-checks/checks/tooltips/CertificateTooltip";
import { GeoLocationTooltip } from "@/modules/posture-checks/checks/tooltips/GeoLocationTooltip";
import { NetBirdVersionTooltip } from "@/modules/posture-checks/checks/tooltips/NetBirdVersionTooltip";
import { OperatingSystemTooltip } from "@/modules/posture-checks/checks/tooltips/OperatingSystemTooltip";
@@ -103,18 +96,6 @@ export const PostureCheckChecksCell = ({
</div>
</ProcessTooltip>
)}
{check.checks.certificate_check && (
<CertificateTooltip check={check.checks.certificate_check}>
<div
className={cn(
"bg-gradient-to-tr from-purple-500 to-purple-400 h-8 w-8 rounded-full flex items-center justify-center relative z-[8] hover:scale-[1.1] transition-all",
)}
>
<FileKey2Icon size={14} />
</div>
</CertificateTooltip>
)}
</div>
{children}
</div>
+1 -11
View File
@@ -22,8 +22,7 @@ interface PostureCheckAction {
| "location"
| "os"
| "network_range"
| "process_check"
| "certificate_check";
| "process_check";
payload: any;
}
@@ -80,15 +79,6 @@ const postureCheckReducer = (
},
};
case "certificate_check":
return {
...state,
checks: {
...state.checks,
certificate_check: action.payload,
},
};
default:
return state;
}
@@ -18,6 +18,7 @@ import { useApiCall } from "@utils/api";
import { cn, validator } from "@utils/helpers";
import {
AlertTriangle,
BugIcon,
ClockFadingIcon,
ExternalLinkIcon,
MonitorSmartphoneIcon,
@@ -96,6 +97,10 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
account.settings?.auto_update_always ?? false,
);
const [debugBundleUploadUrl, setDebugBundleUploadUrl] = useState(
account.settings?.debug_bundle_upload_url ?? "",
);
const [peerExposeEnabled, setPeerExposeEnabled] = useState<boolean>(
account?.settings?.peer_expose_enabled ?? false,
);
@@ -112,6 +117,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
autoUpdateMethod,
autoUpdateCustomVersion,
autoUpdateAlways,
debugBundleUploadUrl,
peerExposeEnabled,
peerExposeGroupNames,
]);
@@ -132,6 +138,21 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
return "";
}, [autoUpdateCustomVersion]);
// Mirrors the rule the API enforces. https only: the client fetches an upload
// URL from this endpoint and then PUTs the bundle to whatever comes back, so a
// plaintext hop is a place to intercept both.
const debugBundleUploadUrlError = useMemo(() => {
if (debugBundleUploadUrl === "") return "";
try {
const parsed = new URL(debugBundleUploadUrl);
if (parsed.protocol !== "https:") return "The URL must use https";
if (!parsed.hostname) return "The URL must have a host";
return "";
} catch {
return "Please enter a valid URL, e.g., https://upload.example.com/upload-url";
}
}, [debugBundleUploadUrl]);
const canSaveCustomVersion =
autoUpdateCustomVersion !== "" &&
autoUpdateMethod === "custom" &&
@@ -142,6 +163,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
!hasChanges ||
!permission.settings.update ||
(autoUpdateMethod === "custom" && !canSaveCustomVersion) ||
debugBundleUploadUrlError !== "" ||
(peerExposeEnabled && peerExposeGroups.length === 0)
);
}, [
@@ -149,6 +171,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
permission.settings.update,
autoUpdateMethod,
canSaveCustomVersion,
debugBundleUploadUrlError,
peerExposeEnabled,
peerExposeGroups,
]);
@@ -169,6 +192,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
...account.settings,
auto_update_version: autoUpdateCustomVersion || autoUpdateMethod,
auto_update_always: autoUpdateAlways,
debug_bundle_upload_url: debugBundleUploadUrl,
peer_expose_enabled: peerExposeEnabled,
peer_expose_groups: peerExposeGroupIds,
},
@@ -179,6 +203,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
autoUpdateMethod,
autoUpdateCustomVersion,
autoUpdateAlways,
debugBundleUploadUrl,
peerExposeEnabled,
peerExposeGroupNames,
]);
@@ -398,6 +423,41 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
</div>
</div>
<div>
<Label>
<BugIcon size={15} />
Debug Bundle Upload
</Label>
<HelpText>
Where clients send debug bundles. A bundle carries peer logs,
routes, DNS and firewall state, so this decides whose
infrastructure that data lands on. Leave it empty to use the
service NetBird runs.{" "}
<InlineLink
href={"https://docs.netbird.io/manage/peers/remote-jobs"}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</HelpText>
<div className={"mt-2"}>
<Input
value={debugBundleUploadUrl}
customPrefix={"URL"}
placeholder={"e.g., https://upload.example.com/upload-url"}
error={debugBundleUploadUrlError}
errorTooltip={true}
disabled={!permission.settings.update}
data-cy={"debug-bundle-upload-url"}
onChange={(v) => {
setDebugBundleUploadUrl(v.target.value);
}}
/>
</div>
</div>
<div>
<Label>
<ClockFadingIcon size={15} />
Loaded 20 of 21 files, more files were not shown because too many files have changed in this diff. Show more