Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/interfaces/ReverseProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ export interface ReverseProxyAuth {
link_auth?: {
enabled: boolean;
};
mtls_auth?: {
enabled: boolean;
ca_cert_pem?: string;
};
header_auths?: HeaderAuthConfig[];
}

Expand Down
59 changes: 59 additions & 0 deletions src/modules/reverse-proxy/ReverseProxyModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal";
import AuthHeaderModal from "@/modules/reverse-proxy/auth/AuthHeaderModal";
import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal";
import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal";
import AuthMTLSModal from "@/modules/reverse-proxy/auth/AuthMTLSModal";
import ReverseProxyHTTPTargets from "@/modules/reverse-proxy/ReverseProxyHTTPTargets";
import ReverseProxyLayer4Content from "@/modules/reverse-proxy/ReverseProxyLayer4Content";
import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal";
Expand Down Expand Up @@ -242,6 +243,12 @@ export default function ReverseProxyModal({
const [linkAuthEnabled, setLinkAuthEnabled] = useState(
reverseProxy?.auth?.link_auth?.enabled ?? false,
);
const [mtlsEnabled, setMTLSEnabled] = useState(
reverseProxy?.auth?.mtls_auth?.enabled ?? false,
);
const [mtlsCACertPEM, setMTLSCACertPEM] = useState(
reverseProxy?.auth?.mtls_auth?.ca_cert_pem ?? "",
);

const [headerAuthsEnabled, setHeaderAuthsEnabled] = useState(
(reverseProxy?.auth?.header_auths ?? []).some((h) => h.enabled),
Expand All @@ -261,6 +268,7 @@ export default function ReverseProxyModal({
const [ssoModalOpen, setSsoModalOpen] = useState(false);
const [pinModalOpen, setPinModalOpen] = useState(false);
const [headerModalOpen, setHeaderModalOpen] = useState(false);
const [mtlsModalOpen, setMTLSModalOpen] = useState(false);

// Target being added/edited
const [targetModalOpen, setTargetModalOpen] = useState(false);
Expand Down Expand Up @@ -331,11 +339,21 @@ export default function ReverseProxyModal({
);
};

const canReuseStoredMTLSCert =
reverseProxy?.auth?.mtls_auth?.enabled === true;
const mtlsError =
mtlsEnabled &&
!canReuseStoredMTLSCert &&
mtlsCACertPEM.trim().length === 0
? "CA certificate PEM is required when mTLS is enabled"
: undefined;

const isUnprotected =
!passwordEnabled &&
!pinEnabled &&
!bearerEnabled &&
!linkAuthEnabled &&
!mtlsEnabled &&
!headerAuthsEnabled &&
!accessRestrictions;

Expand Down Expand Up @@ -371,6 +389,13 @@ export default function ReverseProxyModal({
link_auth: {
enabled: linkAuthEnabled,
},
mtls_auth: {
enabled: mtlsEnabled,
ca_cert_pem:
mtlsEnabled && mtlsCACertPEM.trim().length > 0
? mtlsCACertPEM
: undefined,
},
header_auths: headerAuthsEnabled
? headerAuths.map((h) => ({ ...h, enabled: true }))
: [],
Expand Down Expand Up @@ -569,6 +594,17 @@ export default function ReverseProxyModal({
enabled={pinEnabled}
onClick={() => setPinModalOpen(true)}
/>
<SettingCard.Item
label={
<>
<ShieldCheckIcon size={15} />
mTLS
</>
}
description="Require clients to present a certificate signed by your trusted CA."
enabled={mtlsEnabled}
onClick={() => setMTLSModalOpen(true)}
/>
<SettingCard.Item
label={
<>
Expand Down Expand Up @@ -742,6 +778,7 @@ export default function ReverseProxyModal({
<Button
variant={"primary"}
onClick={() => setTab("access-control")}
disabled={!!mtlsError}
>
Continue
</Button>
Expand Down Expand Up @@ -780,6 +817,7 @@ export default function ReverseProxyModal({
!canContinueToSettings ||
!permission?.services?.create ||
!!timeoutError ||
!!mtlsError ||
accessControlHasErrors
}
onClick={handleSubmit}
Expand All @@ -801,6 +839,7 @@ export default function ReverseProxyModal({
!canContinueToSettings ||
!permission?.services?.update ||
!!timeoutError ||
!!mtlsError ||
accessControlHasErrors
}
onClick={handleSubmit}
Expand Down Expand Up @@ -915,6 +954,26 @@ export default function ReverseProxyModal({
}, 200);
}}
/>

<AuthMTLSModal
open={mtlsModalOpen}
onOpenChange={setMTLSModalOpen}
key={mtlsModalOpen ? "m1" : "m0"}
currentCACertPEM={mtlsCACertPEM}
isEnabled={mtlsEnabled}
onSave={(caCertPEM) => {
setTimeout(() => {
setMTLSCACertPEM(caCertPEM);
setMTLSEnabled(true);
}, 200);
}}
onRemove={() => {
setTimeout(() => {
setMTLSCACertPEM("");
setMTLSEnabled(false);
}, 200);
}}
/>
</Modal>
);
}
241 changes: 241 additions & 0 deletions src/modules/reverse-proxy/auth/AuthMTLSModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import { Label } from "@components/Label";
import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { Textarea } from "@components/Textarea";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import { cn } from "@utils/helpers";
import { FileUp } from "lucide-react";
import React, { useMemo, useRef, useState } from "react";

const MASKED_VALUE = "••••••••";

function validateCertificatePEM(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed) return "CA certificate PEM is required";

const matches = trimmed.match(
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g,
);

if (!matches || matches.length === 0) {
return "Enter a valid PEM certificate";
}

for (const cert of matches) {
const base64Body = cert
.replace(/-----BEGIN CERTIFICATE-----/g, "")
.replace(/-----END CERTIFICATE-----/g, "")
.replace(/\s+/g, "");

if (!base64Body) {
return "Enter a valid PEM certificate";
}

let decoded = "";
try {
decoded = atob(base64Body);
} catch {
return "Certificate PEM contains invalid base64 data";
}

if (!decoded || decoded.charCodeAt(0) !== 0x30) {
return "Certificate PEM does not contain a valid X.509 certificate";
}
}

const leftover = trimmed
.replace(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g, "")
.replace(/^\s*#.*$/gm, "")
.trim();
if (leftover.length > 0) {
return "Only PEM certificate data is allowed";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return undefined;
}

type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
currentCACertPEM: string;
isEnabled: boolean;
onSave: (caCertPEM: string) => void;
onRemove: () => void;
};

export default function AuthMTLSModal({
open,
onOpenChange,
currentCACertPEM,
isEnabled,
onSave,
onRemove,
}: Readonly<Props>) {
const [caCertPEM, setCACertPEM] = useState(currentCACertPEM);
const [isMasked, setIsMasked] = useState(isEnabled && currentCACertPEM === "");
const isEditing = isEnabled;
const inputRef = useRef<HTMLInputElement>(null);

const validationError = useMemo(() => {
if (isMasked) return undefined;
if (!caCertPEM.trim()) return undefined;
return validateCertificatePEM(caCertPEM);
}, [caCertPEM, isMasked]);

const handleSave = () => {
if (isMasked) {
onOpenChange(false);
onSave("");
return;
}

const error = validateCertificatePEM(caCertPEM);
if (error) return;

onOpenChange(false);
onSave(caCertPEM);
};

const handleRemove = () => {
onOpenChange(false);
setCACertPEM("");
setIsMasked(false);
onRemove();
};

const handleFileText = (text: string) => {
setIsMasked(false);
setCACertPEM(text);
};

const handleFileUpload = (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
const fileReader = new FileReader();
fileReader.readAsText(file, "UTF-8");
fileReader.onload = (e) => {
if (e.target === null) return;
handleFileText(e.target.result as string);
};
};

return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass="max-w-2xl">
<ModalHeader
title="mTLS"
description="Require clients to present a certificate signed by your trusted CA."
/>

<GradientFadedBackground />

<div className="px-8">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="mtls-ca-cert-pem">Client CA Certificate PEM</Label>
<Textarea
id="mtls-ca-cert-pem"
aria-label="Client CA certificate PEM"
placeholder="-----BEGIN CERTIFICATE-----"
value={isMasked ? MASKED_VALUE : caCertPEM}
onChange={(e) => {
if (isMasked) {
setIsMasked(false);
setCACertPEM(e.target.value.replace(/•/g, ""));
} else {
setCACertPEM(e.target.value);
}
}}
error={validationError}
className="min-h-[160px] font-mono text-xs"
resize
/>
<HelpText margin={false}>
Paste one PEM certificate or a PEM certificate bundle for the client CA.
</HelpText>
</div>

<div
className={cn(
"flex gap-5 border border-dashed hover:border-nb-gray-600/50 rounded-md border-nb-gray-600/40 items-center justify-center group/upload",
"bg-nb-gray-930/50 hover:bg-nb-gray-930/40 cursor-pointer transition-all px-4 pb-8 pt-6",
)}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
}}
role="button"
tabIndex={0}
aria-label="Upload client CA certificate file"
>
<input
ref={inputRef}
type="file"
className="sr-only"
accept=".pem,.crt,.cer,.txt"
onChange={(e) => handleFileUpload(e.target.files)}
/>
<div className="bg-nb-gray-930 p-2.5 rounded-md mt-0.5 group-hover/upload:bg-nb-gray-930/80 transition-all">
<FileUp size={20} className="text-netbird" />
</div>
<div>
<p className="text-[14px] font-medium text-nb-gray-100">
Upload certificate file
</p>
<p className="text-xs !text-nb-gray-300 mt-1">
<span className="underline underline-offset-4 group-hover/upload:text-nb-gray-200 transition-all">
Click to upload
</span>{" "}
or paste the PEM directly above
</p>
</div>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>

<div className="flex gap-3 w-full justify-between mt-6">
{isEditing ? (
<>
<Button variant="danger-text" onClick={handleRemove}>
Remove
</Button>
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={(!isMasked && !caCertPEM.trim()) || !!validationError}
>
Save
</Button>
</div>
</>
) : (
<>
<div />
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={!caCertPEM.trim() || !!validationError}
>
Add mTLS
</Button>
</div>
</>
)}
</div>
</div>
</ModalContent>
</Modal>
);
}
Loading