mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2ed029fa8 | ||
|
|
e8ee2d1093 | ||
|
|
cba2613e68 | ||
|
|
fe32179dc1 | ||
|
|
1bad86b277 |
No files matched your search
@@ -1,6 +1,7 @@
|
||||
# Ignore zitadel environment
|
||||
.env
|
||||
Caddyfile
|
||||
agentgateway-stub.Caddyfile
|
||||
management.json
|
||||
turnserver.conf
|
||||
zitadel.env
|
||||
|
||||
@@ -19,7 +19,7 @@ check_docker_compose() {
|
||||
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
|
||||
|
||||
$DOCKER_COMPOSE_COMMAND down --volumes
|
||||
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 -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 -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
|
||||
@@ -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 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 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 "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
|
||||
exit 1
|
||||
fi
|
||||
@@ -540,6 +540,7 @@ initEnvironment() {
|
||||
echo Rendering initial files...
|
||||
renderDockerCompose > docker-compose.yml
|
||||
renderCaddyfile > Caddyfile
|
||||
renderAgentGatewayStub > agentgateway-stub.Caddyfile
|
||||
renderZitadelEnv > zitadel.env
|
||||
echo "" > turnserver.conf
|
||||
echo "" > management.json
|
||||
@@ -612,6 +613,30 @@ 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
|
||||
{
|
||||
@@ -818,6 +843,15 @@ 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}
|
||||
|
||||
@@ -24,6 +24,31 @@ 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,7 +59,12 @@ test.describe
|
||||
.getByTestId("agent-network-provider-option-agentgateway")
|
||||
.click({ force: true });
|
||||
|
||||
const upstreamURL = "https://agentgateway.e2e.example";
|
||||
// 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";
|
||||
await page
|
||||
.getByTestId("agent-network-provider-upstream-url")
|
||||
.fill(upstreamURL);
|
||||
|
||||
@@ -364,13 +364,17 @@ 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/);
|
||||
await page.evaluate(
|
||||
() => (document.activeElement as HTMLElement | null)?.blur(),
|
||||
);
|
||||
// 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.keyboard.press("Backspace");
|
||||
await expect(canvasNode(page, "group-new-")).toHaveCount(0);
|
||||
await expectChangeCount(page, 0);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
dismissBlockingOverlays,
|
||||
enterDraft,
|
||||
openControlCenter,
|
||||
openGroupView,
|
||||
resetDraftState,
|
||||
reviewButton,
|
||||
} from "../helpers/control-center";
|
||||
@@ -37,7 +38,6 @@ 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,7 +49,9 @@ test.describe
|
||||
src.id,
|
||||
dst.id,
|
||||
);
|
||||
await openControlCenter(page, "groups");
|
||||
// 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 enterDraft(page);
|
||||
const policyNode = canvasNode(page, `policy-${policy.id}`);
|
||||
await expect(policyNode).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
clickContextMenuItem,
|
||||
dismissBlockingOverlays,
|
||||
openControlCenter,
|
||||
openGroupView,
|
||||
resetDraftState,
|
||||
switchFlowView,
|
||||
} from "../helpers/control-center";
|
||||
@@ -47,8 +48,6 @@ 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);
|
||||
|
||||
@@ -64,15 +63,10 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
|
||||
);
|
||||
|
||||
const policyNode = canvasNode(page, `policy-${policy.id}`);
|
||||
// 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;
|
||||
}
|
||||
// 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);
|
||||
await expect(policyNode).toBeVisible({ timeout: 10_000 });
|
||||
return { src, dst, policy, policyNode };
|
||||
}
|
||||
@@ -512,7 +506,6 @@ 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);
|
||||
|
||||
@@ -529,7 +522,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
|
||||
[],
|
||||
);
|
||||
|
||||
await openControlCenter(page, "groups");
|
||||
await openGroupView(page, src.name);
|
||||
const groupNode = canvasNode(page, `group-${dst.id}`);
|
||||
await expect(groupNode).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
@@ -610,7 +603,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
|
||||
);
|
||||
await createPolicy(page, generateRandomName(PREFIX), src.id, d2.id);
|
||||
|
||||
await openControlCenter(page, "groups");
|
||||
await openGroupView(page, src.name);
|
||||
const policyNode = canvasNode(page, `policy-${p1.id}`);
|
||||
await expect(policyNode).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
|
||||
@@ -35,7 +35,6 @@ 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;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,14 @@ 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;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface PostureCheck {
|
||||
geo_location_check?: GeoLocationCheck;
|
||||
peer_network_range_check?: PeerNetworkRangeCheck;
|
||||
process_check?: ProcessCheck;
|
||||
certificate_check?: CertificateCheck;
|
||||
};
|
||||
policies?: Policy[];
|
||||
active?: boolean;
|
||||
@@ -65,6 +66,10 @@ 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 {
|
||||
addNode,
|
||||
addEdge,
|
||||
addNode,
|
||||
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,6 +39,7 @@ 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));
|
||||
}),
|
||||
@@ -80,7 +81,6 @@ export function usePeerView() {
|
||||
type: "smart",
|
||||
data: { enabled, policy },
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
addDestinationResourceNodes(
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
FileText,
|
||||
PlusCircle,
|
||||
Shield,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
@@ -19,6 +20,13 @@ 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";
|
||||
@@ -36,10 +44,15 @@ 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);
|
||||
@@ -50,8 +63,18 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
|
||||
|
||||
validLogFileCount = logFileCountNumber >= 1 && logFileCountNumber <= 1000;
|
||||
|
||||
return validLogFileCount && validBundleFor;
|
||||
}, [bundleForTime, logFileCount]);
|
||||
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]);
|
||||
|
||||
const createDebugJob = async () => {
|
||||
notify({
|
||||
@@ -64,11 +87,13 @@ 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -172,6 +197,55 @@ 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">
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
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
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
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,6 +14,7 @@ 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";
|
||||
@@ -56,7 +57,8 @@ 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.process_check ||
|
||||
!!check?.checks?.certificate_check;
|
||||
const canCreate =
|
||||
!isEmpty(check?.name) &&
|
||||
isAtLeastOneCheckEnabled &&
|
||||
@@ -165,6 +167,18 @@ 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,8 +1,15 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Disc3Icon, FlagIcon, NetworkIcon, ServerCogIcon } from "lucide-react";
|
||||
import {
|
||||
Disc3Icon,
|
||||
FileKey2Icon,
|
||||
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";
|
||||
@@ -96,6 +103,18 @@ 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>
|
||||
|
||||
@@ -22,7 +22,8 @@ interface PostureCheckAction {
|
||||
| "location"
|
||||
| "os"
|
||||
| "network_range"
|
||||
| "process_check";
|
||||
| "process_check"
|
||||
| "certificate_check";
|
||||
payload: any;
|
||||
}
|
||||
|
||||
@@ -79,6 +80,15 @@ const postureCheckReducer = (
|
||||
},
|
||||
};
|
||||
|
||||
case "certificate_check":
|
||||
return {
|
||||
...state,
|
||||
checks: {
|
||||
...state.checks,
|
||||
certificate_check: action.payload,
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useApiCall } from "@utils/api";
|
||||
import { cn, validator } from "@utils/helpers";
|
||||
import {
|
||||
AlertTriangle,
|
||||
BugIcon,
|
||||
ClockFadingIcon,
|
||||
ExternalLinkIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
@@ -97,10 +96,6 @@ 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,
|
||||
);
|
||||
@@ -117,7 +112,6 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
|
||||
autoUpdateMethod,
|
||||
autoUpdateCustomVersion,
|
||||
autoUpdateAlways,
|
||||
debugBundleUploadUrl,
|
||||
peerExposeEnabled,
|
||||
peerExposeGroupNames,
|
||||
]);
|
||||
@@ -138,21 +132,6 @@ 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" &&
|
||||
@@ -163,7 +142,6 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
|
||||
!hasChanges ||
|
||||
!permission.settings.update ||
|
||||
(autoUpdateMethod === "custom" && !canSaveCustomVersion) ||
|
||||
debugBundleUploadUrlError !== "" ||
|
||||
(peerExposeEnabled && peerExposeGroups.length === 0)
|
||||
);
|
||||
}, [
|
||||
@@ -171,7 +149,6 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
|
||||
permission.settings.update,
|
||||
autoUpdateMethod,
|
||||
canSaveCustomVersion,
|
||||
debugBundleUploadUrlError,
|
||||
peerExposeEnabled,
|
||||
peerExposeGroups,
|
||||
]);
|
||||
@@ -192,7 +169,6 @@ 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,
|
||||
},
|
||||
@@ -203,7 +179,6 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
|
||||
autoUpdateMethod,
|
||||
autoUpdateCustomVersion,
|
||||
autoUpdateAlways,
|
||||
debugBundleUploadUrl,
|
||||
peerExposeEnabled,
|
||||
peerExposeGroupNames,
|
||||
]);
|
||||
@@ -423,41 +398,6 @@ 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
Reference in new issue
Block a user