diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 698b01a4..95857aea 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -34,4 +34,4 @@ // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable // } // } -// } \ No newline at end of file +// } diff --git a/docker/Dockerfile b/docker/Dockerfile index 360da5e0..9a14b340 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,24 +1,16 @@ -FROM alpine:3.14 +FROM nginx:alpine -RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \ - nginx curl supervisor certbot-nginx && \ - rm -rf /var/cache/apk/* && mkdir -p /run/nginx - -STOPSIGNAL SIGINT EXPOSE 80 -EXPOSE 443 -ENTRYPOINT ["/usr/bin/supervisord","-c","/etc/supervisord.conf"] WORKDIR /usr/share/nginx/html -# copy configuration files -COPY docker/default.conf /etc/nginx/http.d/default.conf + +# Copy nginx configs COPY docker/nginx.conf /etc/nginx/nginx.conf -COPY docker/init_cert.sh /usr/local/init_cert.sh -COPY docker/init_react_envs.sh /usr/local/init_react_envs.sh -RUN chmod +x /usr/local/init_cert.sh && rm /etc/crontabs/root -RUN chmod +x /usr/local/init_react_envs.sh +COPY docker/default.conf /etc/nginx/conf.d/default.conf + +# Copy init script as an entrypoint hook (runs before nginx starts) +COPY docker/init_react_envs.sh /docker-entrypoint.d/40-init-react-envs.sh +RUN chmod +x /docker-entrypoint.d/40-init-react-envs.sh -# configure supervisor -COPY docker/supervisord.conf /etc/supervisord.conf -# copy build files -COPY out/ /usr/share/nginx/html/ \ No newline at end of file +# Copy static build output +COPY out/ /usr/share/nginx/html/ diff --git a/docker/init_react_envs.sh b/docker/init_react_envs.sh index d1c5a182..4324c1e5 100644 --- a/docker/init_react_envs.sh +++ b/docker/init_react_envs.sh @@ -1,67 +1,67 @@ -#!/bin/bash +#!/bin/sh set -e -if [[ -z "${AUTH_AUTHORITY}" ]]; then - if [[ -z "${AUTH0_DOMAIN}" ]]; then +if [ -z "${AUTH_AUTHORITY}" ]; then + if [ -z "${AUTH0_DOMAIN}" ]; then echo "AUTH_AUTHORITY or AUTH0_DOMAIN environment variable must be set" exit 1 fi fi -if [[ -z "${AUTH_CLIENT_ID}" ]]; then - if [[ -z "${AUTH0_CLIENT_ID}" ]]; then +if [ -z "${AUTH_CLIENT_ID}" ]; then + if [ -z "${AUTH0_CLIENT_ID}" ]; then echo "AUTH_CLIENT_ID or AUTH0_CLIENT_ID environment variable must be set" exit 1 fi fi -if [[ -z "${AUTH_AUDIENCE}" ]]; then - if [[ -z "${AUTH0_AUDIENCE}" ]]; then +if [ -z "${AUTH_AUDIENCE}" ]; then + if [ -z "${AUTH0_AUDIENCE}" ]; then echo "AUTH_AUDIENCE or AUTH0_AUDIENCE environment variable must be set" exit 1 fi fi -if [[ "${AUTH_AUDIENCE}" == "none" ]]; then +if [ "${AUTH_AUDIENCE}" = "none" ]; then unset AUTH_AUDIENCE fi -if [[ -z "${AUTH_SUPPORTED_SCOPES}" ]]; then - if [[ -z "${AUTH0_DOMAIN}" ]]; then +if [ -z "${AUTH_SUPPORTED_SCOPES}" ]; then + if [ -z "${AUTH0_DOMAIN}" ]; then echo "AUTH_SUPPORTED_SCOPES environment variable must be set" exit 1 fi fi -if [[ -z "${USE_AUTH0}" ]]; then - if [[ -z "${AUTH0_DOMAIN}" ]]; then +if [ -z "${USE_AUTH0}" ]; then + if [ -z "${AUTH0_DOMAIN}" ]; then echo "USE_AUTH0 environment variable must be set" exit 1 fi fi -if [[ -z "${NETBIRD_MGMT_API_ENDPOINT}" ]]; then +if [ -z "${NETBIRD_MGMT_API_ENDPOINT}" ]; then echo "NETBIRD_MGMT_API_ENDPOINT environment variable must be set" exit 1 fi -export AUTH_AUTHORITY=${AUTH_AUTHORITY:-https://$AUTH0_DOMAIN} -export AUTH_CLIENT_ID=${AUTH_CLIENT_ID:-$AUTH0_CLIENT_ID} -export AUTH_CLIENT_SECRET=${AUTH_CLIENT_SECRET} -export AUTH_AUDIENCE=${AUTH_AUDIENCE:-$AUTH0_AUDIENCE} -export AUTH_REDIRECT_URI=${AUTH_REDIRECT_URI} -export AUTH_SILENT_REDIRECT_URI=${AUTH_SILENT_REDIRECT_URI} -export USE_AUTH0=${USE_AUTH0:-true} -export AUTH_SUPPORTED_SCOPES=${AUTH_SUPPORTED_SCOPES:-openid profile email api offline_access email_verified} +export AUTH_AUTHORITY="${AUTH_AUTHORITY:-https://${AUTH0_DOMAIN}}" +export AUTH_CLIENT_ID="${AUTH_CLIENT_ID:-${AUTH0_CLIENT_ID}}" +export AUTH_CLIENT_SECRET="${AUTH_CLIENT_SECRET}" +export AUTH_AUDIENCE="${AUTH_AUDIENCE:-${AUTH0_AUDIENCE}}" +export AUTH_REDIRECT_URI="${AUTH_REDIRECT_URI}" +export AUTH_SILENT_REDIRECT_URI="${AUTH_SILENT_REDIRECT_URI}" +export USE_AUTH0="${USE_AUTH0:-true}" +export AUTH_SUPPORTED_SCOPES="${AUTH_SUPPORTED_SCOPES:-openid profile email api offline_access email_verified}" -export NETBIRD_MGMT_API_ENDPOINT=$(echo $NETBIRD_MGMT_API_ENDPOINT | sed -E 's/(:80|:443)$//') -export NETBIRD_MGMT_GRPC_API_ENDPOINT=${NETBIRD_MGMT_GRPC_API_ENDPOINT} -export NETBIRD_HOTJAR_TRACK_ID=${NETBIRD_HOTJAR_TRACK_ID} -export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID} -export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID} -export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken} -export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false} -export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH} +export NETBIRD_MGMT_API_ENDPOINT="$(echo "$NETBIRD_MGMT_API_ENDPOINT" | sed -E 's/(:80|:443)$//')" +export NETBIRD_MGMT_GRPC_API_ENDPOINT="${NETBIRD_MGMT_GRPC_API_ENDPOINT}" +export NETBIRD_HOTJAR_TRACK_ID="${NETBIRD_HOTJAR_TRACK_ID}" +export NETBIRD_GOOGLE_ANALYTICS_ID="${NETBIRD_GOOGLE_ANALYTICS_ID}" +export NETBIRD_GOOGLE_TAG_MANAGER_ID="${NETBIRD_GOOGLE_TAG_MANAGER_ID}" +export NETBIRD_TOKEN_SOURCE="${NETBIRD_TOKEN_SOURCE:-accessToken}" +export NETBIRD_DRAG_QUERY_PARAMS="${NETBIRD_DRAG_QUERY_PARAMS:-false}" +export NETBIRD_WASM_PATH="${NETBIRD_WASM_PATH}" echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}" @@ -74,4 +74,4 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do cp "$f" "$f".copy envsubst "$ENV_STR" < "$f".copy > "$f" rm "$f".copy -done \ No newline at end of file +done diff --git a/docker/nginx.conf b/docker/nginx.conf index 94024677..5b04b0cf 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -1,6 +1,4 @@ # /etc/nginx/nginx.conf -daemon off; - user nginx; # Set number of worker processes automatically based on number of CPU cores. @@ -136,7 +134,7 @@ http { # Includes virtual hosts configs. - include /etc/nginx/http.d/*.conf; + include /etc/nginx/conf.d/*.conf; } # TIP: Uncomment if you use stream module. diff --git a/package-lock.json b/package-lock.json index 00a5a540..1e03991b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -168,7 +168,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3031,7 +3030,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3041,7 +3039,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3100,7 +3097,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -3581,8 +3577,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@xyflow/react": { "version": "12.10.0", @@ -3621,7 +3616,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4044,7 +4038,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4667,7 +4660,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5196,7 +5188,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5394,7 +5385,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6688,7 +6678,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -7446,7 +7435,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7654,7 +7642,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7695,7 +7682,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8590,7 +8576,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8757,7 +8742,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8923,7 +8907,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9251,7 +9234,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/app/(dashboard)/device-security/devices/page.tsx b/src/app/(dashboard)/device-security/devices/page.tsx new file mode 100644 index 00000000..22a44885 --- /dev/null +++ b/src/app/(dashboard)/device-security/devices/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable from "@components/skeletons/SkeletonTable"; +import { ShieldCheckIcon } from "lucide-react"; +import React, { lazy, Suspense } from "react"; +import PageContainer from "@/layouts/PageContainer"; + +const DevicesTable = lazy( + () => import("@/modules/device-security/DevicesTable"), +); + +export default function DevicesPage() { + return ( + +
+ + } + /> + + +

Device Certificates

+ + Certificates issued to devices in your network. Renew or revoke + certificates to control device access. + +
+ }> + + +
+ ); +} diff --git a/src/app/(dashboard)/device-security/enrollments/page.tsx b/src/app/(dashboard)/device-security/enrollments/page.tsx new file mode 100644 index 00000000..ab928a6a --- /dev/null +++ b/src/app/(dashboard)/device-security/enrollments/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable from "@components/skeletons/SkeletonTable"; +import { ShieldCheckIcon } from "lucide-react"; +import React, { lazy, Suspense } from "react"; +import PageContainer from "@/layouts/PageContainer"; + +const EnrollmentsTable = lazy( + () => import("@/modules/device-security/EnrollmentsTable"), +); + +export default function EnrollmentsPage() { + return ( + +
+ + } + /> + + +

Device Enrollments

+ + Review and manage device enrollment requests. Approve or reject + pending requests to control which devices can connect to your + network. + +
+ }> + + +
+ ); +} diff --git a/src/app/(dashboard)/device-security/inventory/page.tsx b/src/app/(dashboard)/device-security/inventory/page.tsx new file mode 100644 index 00000000..52191cd9 --- /dev/null +++ b/src/app/(dashboard)/device-security/inventory/page.tsx @@ -0,0 +1,2 @@ +import InventoryPage from "@/modules/device-security/InventoryPage"; +export default InventoryPage; diff --git a/src/app/(dashboard)/device-security/layout.tsx b/src/app/(dashboard)/device-security/layout.tsx new file mode 100644 index 00000000..6ad601b6 --- /dev/null +++ b/src/app/(dashboard)/device-security/layout.tsx @@ -0,0 +1,16 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import { DeviceSecurityProvider } from "@/contexts/DeviceSecurityProvider"; +import React from "react"; + +export const metadata: Metadata = { + title: `Device Security - ${globalMetaTitle}`, +}; + +export default function DeviceSecurityLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/(dashboard)/device-security/page.tsx b/src/app/(dashboard)/device-security/page.tsx new file mode 100644 index 00000000..7ce120ef --- /dev/null +++ b/src/app/(dashboard)/device-security/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function DeviceSecurityPage() { + redirect("/device-security/settings"); +} diff --git a/src/app/(dashboard)/device-security/settings/page.tsx b/src/app/(dashboard)/device-security/settings/page.tsx new file mode 100644 index 00000000..012b1181 --- /dev/null +++ b/src/app/(dashboard)/device-security/settings/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import DeviceSecuritySettings from "@/modules/device-security/DeviceSecuritySettings"; + +export default function SettingsPage() { + return ; +} diff --git a/src/app/(dashboard)/device-security/trusted-cas/page.tsx b/src/app/(dashboard)/device-security/trusted-cas/page.tsx new file mode 100644 index 00000000..1f201ad3 --- /dev/null +++ b/src/app/(dashboard)/device-security/trusted-cas/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import { Callout } from "@components/Callout"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable from "@components/skeletons/SkeletonTable"; +import { usePortalElement } from "@hooks/usePortalElement"; +import { ShieldCheckIcon } from "lucide-react"; +import React, { lazy, Suspense } from "react"; +import PageContainer from "@/layouts/PageContainer"; + +const TrustedCAsTable = lazy( + () => import("@/modules/device-security/TrustedCAsTable"), +); + +export default function TrustedCAsPage() { + const { ref: headingRef, portalTarget } = + usePortalElement(); + + return ( + +
+ + } + /> + + +

Trusted CAs

+ + Certificate Authorities trusted for device authentication. Upload CA + certificates when devices bring their own certificates signed by + external authorities. + +
+ + Trusted CAs are used when devices present certificates signed by an + external CA. This is different from the built-in CA used for + certificate issuance. + +
+
+ }> + + +
+ ); +} diff --git a/src/assets/icons/DeviceSecurityIcon.tsx b/src/assets/icons/DeviceSecurityIcon.tsx new file mode 100644 index 00000000..1e1905e9 --- /dev/null +++ b/src/assets/icons/DeviceSecurityIcon.tsx @@ -0,0 +1,19 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function DeviceSecurityIcon(props: IconProps) { + return ( + + + + ); +} diff --git a/src/components/HelpText.tsx b/src/components/HelpText.tsx index 87555949..1e8448a1 100644 --- a/src/components/HelpText.tsx +++ b/src/components/HelpText.tsx @@ -5,14 +5,17 @@ type Props = { children?: React.ReactNode; margin?: boolean; className?: string; + id?: string; }; export default function HelpText({ children, margin = true, className, + id, }: Props) { return ( (props: NotifyProps) { duration: Infinity, }); } + +notify.error = function notifyError(message: string) { + return notify({ + title: "Error", + description: message, + promise: Promise.reject(new Error(message)), + }); +}; diff --git a/src/contexts/DeviceSecurityProvider.tsx b/src/contexts/DeviceSecurityProvider.tsx new file mode 100644 index 00000000..416517eb --- /dev/null +++ b/src/contexts/DeviceSecurityProvider.tsx @@ -0,0 +1,242 @@ +"use client"; + +import useFetchApi, { useApiCall } from "@utils/api"; +import React, { useCallback, useMemo } from "react"; +import { useSWRConfig } from "swr"; +import type { + CAConfig, + CATestResult, + DeviceAuthSettings, + DeviceCert, + DeviceEnrollment, + InventoryConfig, + TrustedCA, +} from "@/interfaces/DeviceSecurity"; + +type DeviceSecurityContextValue = { + // Settings + settings: DeviceAuthSettings | undefined; + settingsLoading: boolean; + updateSettings: (s: Partial) => Promise; + + // Enrollments + enrollments: DeviceEnrollment[] | undefined; + enrollmentsLoading: boolean; + approveEnrollment: (id: string) => Promise; + rejectEnrollment: (id: string, reason?: string) => Promise; + + // Devices (issued certs) + devices: DeviceCert[] | undefined; + devicesLoading: boolean; + revokeDevice: (id: string) => Promise; + renewDevice: (id: string) => Promise; + + // Trusted CAs + trustedCAs: TrustedCA[] | undefined; + trustedCAsLoading: boolean; + addTrustedCA: (name: string, pem: string) => Promise; + deleteTrustedCA: (id: string) => Promise; + + // CA Config + caConfig: CAConfig | undefined; + caConfigLoading: boolean; + updateCAConfig: (config: CAConfig) => Promise; + testCAConnection: (config: CAConfig) => Promise; + + // Inventory Config + inventoryConfig: InventoryConfig | undefined; + inventoryConfigLoading: boolean; + updateInventoryConfig: (config: InventoryConfig) => Promise; +}; + +const DeviceSecurityContext = React.createContext( + {} as DeviceSecurityContextValue, +); + +export function DeviceSecurityProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { mutate } = useSWRConfig(); + + const { data: settings, isLoading: settingsLoading } = + useFetchApi("/device-auth/settings"); + + const { data: enrollments, isLoading: enrollmentsLoading } = + useFetchApi( + "/device-auth/enrollments", + false, + true, + true, + { refreshInterval: 10_000 }, + ); + + const { data: devices, isLoading: devicesLoading } = + useFetchApi("/device-auth/devices"); + + const { data: trustedCAs, isLoading: trustedCAsLoading } = + useFetchApi("/device-auth/trusted-cas"); + + const { data: caConfig, isLoading: caConfigLoading } = + useFetchApi("/device-auth/ca/config"); + + const { data: inventoryConfig, isLoading: inventoryConfigLoading } = + useFetchApi("/device-auth/inventory/config"); + + const settingsRequest = useApiCall( + "/device-auth/settings", + ); + const enrollmentRequest = useApiCall("/device-auth/enrollments"); + const deviceRequest = useApiCall("/device-auth/devices"); + const caRequest = useApiCall("/device-auth/trusted-cas"); + const caConfigRequest = useApiCall("/device-auth/ca/config"); + const caTestRequest = useApiCall("/device-auth/ca/test"); + const inventoryConfigRequest = useApiCall( + "/device-auth/inventory/config", + ); + + const updateSettings = useCallback( + async (s: Partial) => { + const updated = await settingsRequest.put(s); + await mutate("/device-auth/settings"); + return updated; + }, + [settingsRequest, mutate], + ); + + const approveEnrollment = useCallback( + async (id: string) => { + await enrollmentRequest.post(null, `/${id}/approve`); + await mutate("/device-auth/enrollments"); + }, + [enrollmentRequest, mutate], + ); + + const rejectEnrollment = useCallback( + async (id: string, reason?: string) => { + await enrollmentRequest.post(reason ? { reason } : null, `/${id}/reject`); + await mutate("/device-auth/enrollments"); + }, + [enrollmentRequest, mutate], + ); + + const revokeDevice = useCallback( + async (id: string) => { + await deviceRequest.post(null, `/${id}/revoke`); + await mutate("/device-auth/devices"); + }, + [deviceRequest, mutate], + ); + + const renewDevice = useCallback( + async (id: string) => { + await deviceRequest.post(null, `/${id}/cert/renew`); + await mutate("/device-auth/devices"); + }, + [deviceRequest, mutate], + ); + + const addTrustedCA = useCallback( + async (name: string, pem: string) => { + const created = await caRequest.post({ name, pem }); + await mutate("/device-auth/trusted-cas"); + return created; + }, + [caRequest, mutate], + ); + + const deleteTrustedCA = useCallback( + async (id: string) => { + await caRequest.del(null, `/${id}`); + await mutate("/device-auth/trusted-cas"); + }, + [caRequest, mutate], + ); + + const updateCAConfig = useCallback( + async (config: CAConfig) => { + const updated = await caConfigRequest.put(config); + await mutate("/device-auth/ca/config"); + return updated; + }, + [caConfigRequest, mutate], + ); + + const testCAConnection = useCallback( + async (config: CAConfig) => { + return await caTestRequest.post(config); + }, + [caTestRequest], + ); + + const updateInventoryConfig = useCallback( + async (config: InventoryConfig) => { + const updated = await inventoryConfigRequest.put(config); + await mutate("/device-auth/inventory/config"); + return updated; + }, + [inventoryConfigRequest, mutate], + ); + + const value = useMemo( + () => ({ + settings, + settingsLoading, + updateSettings, + enrollments, + enrollmentsLoading, + approveEnrollment, + rejectEnrollment, + devices, + devicesLoading, + revokeDevice, + renewDevice, + trustedCAs, + trustedCAsLoading, + addTrustedCA, + deleteTrustedCA, + caConfig, + caConfigLoading, + updateCAConfig, + testCAConnection, + inventoryConfig, + inventoryConfigLoading, + updateInventoryConfig, + }), + [ + settings, + settingsLoading, + updateSettings, + enrollments, + enrollmentsLoading, + approveEnrollment, + rejectEnrollment, + devices, + devicesLoading, + revokeDevice, + renewDevice, + trustedCAs, + trustedCAsLoading, + addTrustedCA, + deleteTrustedCA, + caConfig, + caConfigLoading, + updateCAConfig, + testCAConnection, + inventoryConfig, + inventoryConfigLoading, + updateInventoryConfig, + ], + ); + + return ( + + {children} + + ); +} + +export const useDeviceSecurity = () => React.useContext(DeviceSecurityContext); +export const useDeviceSecurityContext = () => + React.useContext(DeviceSecurityContext); diff --git a/src/contexts/UsersProvider.tsx b/src/contexts/UsersProvider.tsx index 67a79f6a..c467e2ee 100644 --- a/src/contexts/UsersProvider.tsx +++ b/src/contexts/UsersProvider.tsx @@ -106,6 +106,9 @@ export const useLoggedInUser = () => { const { setGlobalApiParams } = useApplicationContext(); const isOwner = loggedInUser ? loggedInUser?.role === Role.Owner : false; const isAdmin = loggedInUser ? loggedInUser?.role === Role.Admin : false; + const isCertApprover = loggedInUser + ? loggedInUser?.role === Role.CertApprover + : false; const isUser = !isOwner && !isAdmin; const isOwnerOrAdmin = isOwner || isAdmin; @@ -120,6 +123,7 @@ export const useLoggedInUser = () => { loggedInUser, isOwner, isAdmin, + isCertApprover, isUser, isOwnerOrAdmin, logout, diff --git a/src/interfaces/DeviceSecurity.ts b/src/interfaces/DeviceSecurity.ts new file mode 100644 index 00000000..743f5532 --- /dev/null +++ b/src/interfaces/DeviceSecurity.ts @@ -0,0 +1,127 @@ +export type DeviceAuthMode = + | "disabled" + | "optional" + | "cert-only" + | "cert-and-sso"; +export type EnrollmentMode = "manual" | "attestation" | "both"; +export type CAType = "builtin" | "vault" | "smallstep" | "scep"; + +export interface DeviceAuthSettings { + mode: DeviceAuthMode; + enrollment_mode: EnrollmentMode; + ca_type: CAType; + cert_validity_days: number; + inventory_type: string; + require_inventory_check: boolean; +} + +export interface DeviceEnrollment { + id: string; + peer_id: string; + wg_public_key: string; + status: "pending" | "approved" | "rejected"; + reason?: string; + created_at: string; +} + +export interface DeviceCert { + id: string; + peer_id: string; + wg_public_key: string; + serial: string; + not_before: string; + not_after: string; + revoked: boolean; + revoked_at?: string; +} + +export interface TrustedCA { + id: string; + name: string; + created_at: string; + pem?: string; +} + +// CA config types — for /device-auth/ca/config endpoints + +export interface VaultCAConfig { + address: string; + token: string; // empty in GET response (redacted) + has_token: boolean; + mount: string; + role: string; + namespace: string; + timeout_seconds: number; +} + +export interface SmallstepCAConfig { + url: string; + provisioner_token: string; // empty in GET response + has_provisioner_token: boolean; + fingerprint: string; + timeout_seconds: number; +} + +export interface SCEPCAConfig { + url: string; + challenge: string; // empty in GET response + has_challenge: boolean; + timeout_seconds: number; +} + +export interface CAConfig { + ca_type: CAType; + vault?: VaultCAConfig; + smallstep?: SmallstepCAConfig; + scep?: SCEPCAConfig; +} + +// CA test result types — for /device-auth/ca/test endpoint + +export type CATestStepStatus = "ok" | "error" | "skipped"; + +export interface CATestStep { + name: string; + status: CATestStepStatus; + detail: string; + fix_hint?: string; + elapsed_ms: number; +} + +export interface CATestResult { + success: boolean; + steps: CATestStep[]; +} + +// Inventory config types — for /device-auth/inventory/config endpoints +// Multiple sources can be enabled simultaneously (e.g., Intune for Windows + Jamf for Mac). + +export interface StaticInventoryConfig { + enabled: boolean; + peers: string[]; // WireGuard public keys + serials: string[]; // hardware serial numbers (string format, e.g. "C02XL1YLJHD5") +} + +export interface IntuneInventoryConfig { + enabled: boolean; + tenant_id: string; + client_id: string; + client_secret: string; // empty in GET response + has_client_secret: boolean; + require_compliance: boolean; +} + +export interface JamfInventoryConfig { + enabled: boolean; + jamf_url: string; + client_id: string; + client_secret: string; // empty in GET response + has_client_secret: boolean; + require_management: boolean; +} + +export interface InventoryConfig { + static: StaticInventoryConfig; + intune: IntuneInventoryConfig; + jamf: JamfInventoryConfig; +} diff --git a/src/interfaces/User.ts b/src/interfaces/User.ts index 9d6cf531..cb0adf1a 100644 --- a/src/interfaces/User.ts +++ b/src/interfaces/User.ts @@ -69,4 +69,5 @@ export enum Role { BillingAdmin = "billing_admin", Auditor = "auditor", NetworkAdmin = "network_admin", + CertApprover = "cert_approver", } diff --git a/src/layouts/Navigation.tsx b/src/layouts/Navigation.tsx index 4f14c695..878bf6ac 100644 --- a/src/layouts/Navigation.tsx +++ b/src/layouts/Navigation.tsx @@ -5,6 +5,7 @@ import { cn } from "@utils/helpers"; import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import ControlCenterIcon from "@/assets/icons/ControlCenterIcon"; import DNSIcon from "@/assets/icons/DNSIcon"; +import DeviceSecurityIcon from "@/assets/icons/DeviceSecurityIcon"; import DocsIcon from "@/assets/icons/DocsIcon"; import PeerIcon from "@/assets/icons/PeerIcon"; import SettingsIcon from "@/assets/icons/SettingsIcon"; @@ -193,6 +194,58 @@ export default function Navigation({ visible={permission.dns.read} /> + } + label={ +
+ Device Security + +
+ } + collapsible + visible={!isRestricted} + > + + + + + + } label="Team" diff --git a/src/modules/device-security/AddTrustedCAModal.tsx b/src/modules/device-security/AddTrustedCAModal.tsx new file mode 100644 index 00000000..8fa4f765 --- /dev/null +++ b/src/modules/device-security/AddTrustedCAModal.tsx @@ -0,0 +1,140 @@ +"use client"; + +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, + ModalTrigger, +} from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import { notify } from "@components/Notification"; +import Separator from "@components/Separator"; +import { Textarea } from "@components/Textarea"; +import { ShieldCheckIcon, PlusCircle } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { useDeviceSecurity } from "@/contexts/DeviceSecurityProvider"; + +const PEM_HEADER = "-----BEGIN CERTIFICATE-----"; +const PEM_FOOTER = "-----END CERTIFICATE-----"; + +type Props = { + children: React.ReactNode; +}; + +export default function AddTrustedCAModal({ children }: Readonly) { + const [open, setOpen] = useState(false); + + return ( + + {children} + setOpen(false)} /> + + ); +} + +type ContentProps = { + onSuccess: () => void; +}; + +function AddTrustedCAModalContent({ onSuccess }: Readonly) { + const { addTrustedCA } = useDeviceSecurity(); + + const [name, setName] = useState(""); + const [pem, setPem] = useState(""); + + const pemError = useMemo(() => { + const trimmed = pem.trim(); + if (trimmed.length === 0) return undefined; + if (!trimmed.startsWith(PEM_HEADER)) { + return `Must start with "${PEM_HEADER}"`; + } + if (!trimmed.endsWith(PEM_FOOTER)) { + return `Must end with "${PEM_FOOTER}"`; + } + return undefined; + }, [pem]); + + const isDisabled = useMemo(() => { + return ( + name.trim().length === 0 || + pem.trim().length === 0 || + pemError !== undefined + ); + }, [name, pem, pemError]); + + const handleSubmit = () => { + const trimmedName = name.trim(); + const trimmedPem = pem.trim(); + + notify({ + title: "Adding trusted CA", + description: `"${trimmedName}" was added successfully`, + promise: addTrustedCA(trimmedName, trimmedPem).then(() => { + onSuccess(); + }), + loadingMessage: "Adding trusted CA...", + }); + }; + + return ( + + } + title="Add Trusted CA" + description="Upload a trusted Certificate Authority to authenticate devices" + color="netbird" + /> + + + +
+
+ + A descriptive name for this Certificate Authority + setName(e.target.value)} + /> +
+ +
+ + + Paste the PEM-encoded certificate. Must begin with{" "} + {PEM_HEADER} + +