diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index a5cd5504..8ae446cb 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -43,6 +43,7 @@ import { NetworkIcon, PencilIcon, RadioTowerIcon, + ShieldCheckIcon, } from "lucide-react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -72,6 +73,7 @@ import ReverseProxiesProvider, { useReverseProxies, } from "@/contexts/ReverseProxiesProvider"; import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent"; +import { PeerCertificatesSection } from "@/modules/peer/PeerCertificatesSection"; import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle"; import { RDPButton } from "@/modules/remote-access/rdp/RDPButton"; import { SSHButton } from "@/modules/remote-access/ssh/SSHButton"; @@ -374,6 +376,13 @@ const PeerOverviewTabs = () => { Remote Jobs )} + + {peer?.id && permission.certificate_authority?.read && ( + + + Certificates + + )} @@ -411,6 +420,12 @@ const PeerOverviewTabs = () => { )} + + {peer?.id && permission.certificate_authority?.read && ( + + + + )} ); }; diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 149602f9..5114fcb3 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -9,6 +9,7 @@ import { LockIcon, MonitorSmartphoneIcon, NetworkIcon, + ShieldCheckIcon, ShieldIcon, } from "lucide-react"; import { useSearchParams } from "next/navigation"; @@ -23,6 +24,7 @@ import DangerZoneTab from "@/modules/settings/DangerZoneTab"; import IdentityProvidersTab from "@/modules/settings/IdentityProvidersTab"; import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab"; import PermissionsTab from "@/modules/settings/PermissionsTab"; +import CertificateAuthorityTab from "@/modules/settings/CertificateAuthorityTab"; import GroupsSettings from "@/modules/settings/GroupsSettings"; export default function NetBirdSettings() { @@ -78,6 +80,12 @@ export default function NetBirdSettings() { Clients + {permission.certificate_authority?.read && ( + + + Certificate Authority + + )} )} @@ -95,6 +103,9 @@ export default function NetBirdSettings() { {account && } {account && } {account && } + {account && permission.certificate_authority?.read && ( + + )} {account && } diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index 70fe17f0..bd9a11d0 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -12,6 +12,7 @@ export interface Account { peer_login_expiration_enabled: boolean; peer_expose_enabled?: boolean; peer_expose_groups?: string[]; + cert_wildcard_allowed?: boolean; peer_login_expiration: number; peer_inactivity_expiration_enabled: boolean; peer_inactivity_expiration: number; diff --git a/src/interfaces/CertificateAuthority.ts b/src/interfaces/CertificateAuthority.ts new file mode 100644 index 00000000..c3a618d9 --- /dev/null +++ b/src/interfaces/CertificateAuthority.ts @@ -0,0 +1,24 @@ +export interface CACertificate { + id: string; + fingerprint: string; + display_name?: string; + organization?: string; + is_active: boolean; + not_before: string; + not_after: string; + created_at: string; + certificate_pem?: string; +} + +export interface IssuedCertificate { + id: string; + peer_id: string; + serial_number: string; + dns_names: string[]; + has_wildcard: boolean; + signing_type: string; + not_before: string; + not_after: string; + created_at: string; + revoked: boolean; +} diff --git a/src/interfaces/Permission.ts b/src/interfaces/Permission.ts index 4eb77d2d..5fc2390a 100644 --- a/src/interfaces/Permission.ts +++ b/src/interfaces/Permission.ts @@ -20,6 +20,7 @@ export interface Permissions { events: Permission; settings: Permission; + certificate_authority: Permission; accounts: Permission; billing: Permission; identity_providers: Permission; diff --git a/src/modules/peer/PeerCertificatesSection.tsx b/src/modules/peer/PeerCertificatesSection.tsx new file mode 100644 index 00000000..af5e5a29 --- /dev/null +++ b/src/modules/peer/PeerCertificatesSection.tsx @@ -0,0 +1,308 @@ +import Badge from "@components/Badge"; +import Button from "@components/Button"; +import Card from "@components/Card"; +import { notify } from "@components/Notification"; +import Paragraph from "@components/Paragraph"; +import SquareIcon from "@components/SquareIcon"; +import GetStartedTest from "@components/ui/GetStartedTest"; +import useFetchApi, { useApiCall } from "@utils/api"; +import dayjs from "dayjs"; +import { + Barcode, + CalendarDays, + ChevronDownIcon, + ChevronRightIcon, + Globe, + ShieldCheckIcon, + ShieldOffIcon, +} from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import { useDialog } from "@/contexts/DialogProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { IssuedCertificate } from "@/interfaces/CertificateAuthority"; + +type Props = { + peerId: string; +}; + +function isCertExpired(cert: IssuedCertificate) { + return dayjs(cert.not_after).isBefore(dayjs()); +} + +function isCertActive(cert: IssuedCertificate) { + return ( + !cert.revoked && + !isCertExpired(cert) && + !dayjs(cert.not_before).isAfter(dayjs()) + ); +} + +function CertStatusBadge({ cert }: { cert: IssuedCertificate }) { + if (cert.revoked) { + return ( + + Revoked + + ); + } + + if (isCertExpired(cert)) { + return ( + + Expired + + ); + } + + if (dayjs(cert.not_before).isAfter(dayjs())) { + return ( + + Pending + + ); + } + + return ( + + Active + + ); +} + +function ActiveCertificateCard({ + cert, + peerId, +}: { + cert: IssuedCertificate; + peerId: string; +}) { + const { confirm } = useDialog(); + const { mutate } = useSWRConfig(); + const { permission } = usePermissions(); + const revokeRequest = useApiCall( + "/ca/certificates/" + cert.serial_number + "/revoke", + ); + + const handleRevoke = async () => { + const choice = await confirm({ + title: `Revoke certificate?`, + description: `Are you sure you want to revoke the certificate for ${cert.dns_names?.join(", ") || cert.serial_number}? This action cannot be undone.`, + confirmText: "Revoke", + cancelText: "Cancel", + type: "danger", + }); + + if (!choice) return; + + notify({ + title: "Revoke Certificate", + description: "Certificate was revoked successfully.", + promise: revokeRequest.post({}).then(() => { + mutate("/ca/certificates?peer_id=" + peerId); + }), + loadingMessage: "Revoking certificate...", + }); + }; + + const primaryDomain = cert.dns_names?.[0] || "-"; + + return ( +
+ + + + + Domain + + } + tooltip={false} + value={ +
+ {primaryDomain} + {cert.has_wildcard && ( + + Wildcard + + )} +
+ } + /> + + Active + + } + /> + + + Issued + + } + value={dayjs(cert.not_before).format("MMM D, YYYY")} + /> + + + Expires + + } + value={ + dayjs(cert.not_after).format("MMM D, YYYY") + + " (" + + dayjs().to(cert.not_after) + + ")" + } + /> + + + Serial Number + + } + value={cert.serial_number} + copy={true} + /> +
+
+
+ +
+
+ ); +} + +function PreviousCertificatesSection({ + certs, +}: { + certs: IssuedCertificate[]; +}) { + const [expanded, setExpanded] = useState(false); + + if (certs.length === 0) return null; + + return ( +
+ + {expanded && ( +
+ {certs.map((cert) => ( +
+ + {cert.dns_names?.[0] || "-"} + + + {dayjs(cert.not_before).format("MMM D, YYYY")} –{" "} + {dayjs(cert.not_after).format("MMM D, YYYY")} + + +
+ ))} +
+ )} +
+ ); +} + +export function PeerCertificatesSection({ peerId }: Props) { + const { + data: certificates, + isLoading, + error, + } = useFetchApi( + "/ca/certificates?peer_id=" + peerId, + ); + + const { activeCert, previousCerts } = useMemo(() => { + if (!certificates || certificates.length === 0) { + return { activeCert: null, previousCerts: [] }; + } + + const sorted = [...certificates].sort( + (a, b) => dayjs(b.not_after).valueOf() - dayjs(a.not_after).valueOf(), + ); + + const active = sorted.find(isCertActive) ?? null; + const previous = sorted.filter((c) => c !== active); + + return { activeCert: active, previousCerts: previous }; + }, [certificates]); + + if (isLoading) return null; + + const hasNoCerts = !certificates || certificates.length === 0; + + return ( +
+ + TLS certificates issued to this peer by your network's Certificate + Authority. + + + {error && ( +
+ Failed to load certificates. Please try again later. +
+ )} + + {!error && hasNoCerts && ( + } + color={"netbird"} + size={"large"} + /> + } + title={"No Certificates Issued"} + description={ + "Certificates will appear here once this peer requests them from the Certificate Authority." + } + /> + )} + + {activeCert && ( + + )} + + +
+ ); +} diff --git a/src/modules/settings/CertificateAuthorityTab.tsx b/src/modules/settings/CertificateAuthorityTab.tsx new file mode 100644 index 00000000..3139bd4f --- /dev/null +++ b/src/modules/settings/CertificateAuthorityTab.tsx @@ -0,0 +1,478 @@ +import Badge from "@components/Badge"; +import Breadcrumbs from "@components/Breadcrumbs"; +import Button from "@components/Button"; +import Card from "@components/Card"; +import FancyToggleSwitch from "@components/FancyToggleSwitch"; +import HelpText from "@components/HelpText"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, +} from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import { notify } from "@components/Notification"; +import Paragraph from "@components/Paragraph"; +import Separator from "@components/Separator"; +import { + SelectDropdown, + SelectOption, +} from "@components/select/SelectDropdown"; +import SquareIcon from "@components/SquareIcon"; +import GetStartedTest from "@components/ui/GetStartedTest"; +import * as Tabs from "@radix-ui/react-tabs"; +import useFetchApi, { useApiCall } from "@utils/api"; +import dayjs from "dayjs"; +import { + AlertTriangleIcon, + ChevronDownIcon, + ChevronRightIcon, + DownloadIcon, + RefreshCwIcon, + ShieldCheckIcon, +} from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import SettingsIcon from "@/assets/icons/SettingsIcon"; +import { useDialog } from "@/contexts/DialogProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { Account } from "@/interfaces/Account"; +import { CACertificate } from "@/interfaces/CertificateAuthority"; + +type Props = { + account: Account; +}; + +const validityOptions: SelectOption[] = [ + { label: "1 year", value: "365" }, + { label: "2 years", value: "730" }, + { label: "5 years", value: "1825" }, + { label: "10 years", value: "3650" }, + { label: "20 years", value: "7300" }, +]; + +function InitCAModal({ + open, + setOpen, + dnsDomain, + onSuccess, +}: { + open: boolean; + setOpen: (open: boolean) => void; + dnsDomain: string; + onSuccess: () => void; +}) { + const initRequest = useApiCall("/ca"); + + const [displayName, setDisplayName] = useState(""); + const [organization, setOrganization] = useState("NetBird Self-Hosted"); + const [validityDays, setValidityDays] = useState("3650"); + + const handleSubmit = () => { + const body: Record = {}; + if (displayName.trim()) { + body.display_name = displayName.trim(); + } + if (organization.trim() && organization.trim() !== "NetBird Self-Hosted") { + body.organization = organization.trim(); + } + const days = parseInt(validityDays); + if (days && days !== 3650) { + body.validity_days = days; + } + + notify({ + title: "Initialize CA", + description: "Certificate Authority was initialized successfully.", + promise: initRequest.post(body).then(() => { + onSuccess(); + setOpen(false); + }), + loadingMessage: "Initializing Certificate Authority...", + }); + }; + + return ( + + + } + title={"Initialize Certificate Authority"} + description={ + "Configure the root CA certificate for your network. All fields are optional and have sensible defaults." + } + color={"netbird"} + /> + +
+
+ + + Used in the certificate CommonName. Leave empty for automatic + naming. + + setDisplayName(e.target.value)} + /> +
+
+ + + The organization name embedded in the CA certificate. + + setOrganization(e.target.value)} + /> +
+
+ + + How long the CA certificate will be valid. Longer is typical for + root CAs. + + +
+
+ + + + + + +
+
+ ); +} + +function CAStatusCard({ ca }: { ca: CACertificate }) { + const { confirm } = useDialog(); + const { mutate } = useSWRConfig(); + const rotateRequest = useApiCall("/ca/rotate"); + const { permission } = usePermissions(); + + const handleRotate = async () => { + const choice = await confirm({ + title: "Rotate Certificate Authority?", + description: + "This will create a new CA and deactivate the current one. Existing certificates will remain valid until they expire. New certificates will be signed by the new CA.", + confirmText: "Rotate", + cancelText: "Cancel", + type: "warning", + }); + + if (!choice) return; + + notify({ + title: "Rotate CA", + description: "Certificate Authority was rotated successfully.", + promise: rotateRequest.post({}).then(() => { + mutate("/ca"); + mutate("/ca/certificates"); + }), + loadingMessage: "Rotating Certificate Authority...", + }); + }; + + const handleDownload = () => { + if (!ca.certificate_pem) return; + const blob = new Blob([ca.certificate_pem], { + type: "application/x-pem-file", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "ca.pem"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( +
+ + + {ca.display_name && ( + + )} + {ca.organization && ( + + )} + + + + + Active + + ) : ( + + Inactive + + ) + } + tooltip={false} + /> + + +
+ {ca.certificate_pem && ( + + )} + +
+
+ ); +} + +function InactiveCAsSection({ cas }: { cas: CACertificate[] }) { + const [expanded, setExpanded] = useState(false); + + if (cas.length === 0) return null; + + return ( +
+ + {expanded && ( +
+ {cas.map((ca) => ( +
+ + {ca.fingerprint} + + + Created {dayjs(ca.created_at).format("MMM D, YYYY")} + + + Expired {dayjs(ca.not_after).format("MMM D, YYYY")} + + + Inactive + +
+ ))} +
+ )} +
+ ); +} + +export default function CertificateAuthorityTab({ + account, +}: Readonly) { + const { permission } = usePermissions(); + const { mutate } = useSWRConfig(); + const { + data: cas, + isLoading: isCAsLoading, + error: caError, + } = useFetchApi("/ca"); + const saveRequest = useApiCall("/accounts/" + account.id, true); + + const [initModalOpen, setInitModalOpen] = useState(false); + const [wildcardAllowed, setWildcardAllowed] = useState( + account.settings.cert_wildcard_allowed ?? false, + ); + + const activeCA = useMemo(() => { + return cas?.find((ca) => ca.is_active); + }, [cas]); + + const inactiveCAs = useMemo(() => { + return cas?.filter((ca) => !ca.is_active) ?? []; + }, [cas]); + + const dnsDomain = account.settings.dns_domain || ""; + const dnsDomainSet = Boolean(dnsDomain); + + const toggleWildcard = async (toggle: boolean) => { + notify({ + title: "Wildcard Certificates", + description: `Wildcard certificates successfully ${toggle ? "enabled" : "disabled"}.`, + promise: saveRequest + .put({ + id: account.id, + settings: { + ...account.settings, + cert_wildcard_allowed: toggle, + }, + }) + .then(() => { + setWildcardAllowed(toggle); + mutate("/accounts"); + }), + loadingMessage: "Updating wildcard setting...", + }); + }; + + return ( + +
+ + } + /> + } + active + /> + +
+
+

Certificate Authority

+ + Manage your network's Certificate Authority settings. + +
+
+
+ + {caError && ( +
+ + Failed to load Certificate Authority data. Please try again later. +
+ )} + + {!isCAsLoading && !caError && !activeCA && ( + <> + {!dnsDomainSet && ( +
+ + + DNS domain must be configured before initializing a CA.{" "} + + Configure DNS domain + + +
+ )} + } + color={"netbird"} + size={"large"} + /> + } + title={"Get Started with Certificate Authority"} + description={ + "Initialize a Certificate Authority to issue and manage TLS certificates for your network peers." + } + button={ + + } + /> + {initModalOpen && ( + mutate("/ca")} + /> + )} + + )} + + {activeCA && } + + {activeCA && ( +
+ + + Allow wildcard certificates + + } + helpText={ + "Peers can request wildcard subdomain certificates (e.g. *.peer.domain)" + } + disabled={!permission.certificate_authority?.update} + /> +
+ )} + + +
+ ); +}