MTLS for all public resources

This commit is contained in:
Owen
2026-10-20 11:51:43 -04:00
parent c799d3ab6b
commit 3510054a92
12 changed files with 181 additions and 98 deletions
+3 -2
View File
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { portRangeStringSchema } from "@server/lib/ip";
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { MtlsSchema } from "#dynamic/lib/blueprints/MtlsSchema";
import { resourceModeSupportsMtls } from "@server/lib/mtlsModes";
import { isValidRegionId } from "@server/db/regions";
import { isValidHttpMethodList } from "@server/lib/validators";
import { wildcardSubdomainSchema } from "@server/lib/schemas";
@@ -481,11 +482,11 @@ export const PublicResourceSchema = z
return true;
}
return (resource.mode ?? resource.protocol) === "http";
return resourceModeSupportsMtls(resource.mode ?? resource.protocol);
},
{
path: ["auth", "mtls"],
error: "mTLS is only supported when mode is 'http'"
error: "mTLS is only supported when mode is 'http', 'ssh', 'rdp', 'vnc' or 'inference'"
}
)
.refine(
+17
View File
@@ -0,0 +1,17 @@
// Resource modes that Traefik serves over HTTP(S) - directly, through the
// browser gateway (SSH/RDP/VNC) or through the AI gateway (inference) - so a
// client certificate can be required during the TLS handshake. Raw TCP/UDP
// resources are excluded.
export const MTLS_RESOURCE_MODES = [
"http",
"ssh",
"rdp",
"vnc",
"inference"
] as const;
export function resourceModeSupportsMtls(
mode: string | null | undefined
): boolean {
return (MTLS_RESOURCE_MODES as readonly string[]).includes(mode ?? "");
}
+19 -1
View File
@@ -1,4 +1,5 @@
import config from "@server/lib/config";
import { applyMtlsConfig } from "./mtls";
import { sanitize } from "./utils";
export type BrowserGatewayResourceRow = {
@@ -116,6 +117,8 @@ export function buildBrowserGatewayResourcesMap(
export function buildBrowserGatewayConfig(params: {
config_output: any;
browserGatewayResourcesMap: Map<number, BrowserGatewayResourceEntry>;
// trusted client-CA PEMs for resources that require mTLS
mtlsCaCertsByResource: Map<number, string[]>;
browserGatewayUiUrl: string;
maintenancePageUiUrl: string | null;
badgerMiddlewareName: string;
@@ -130,6 +133,7 @@ export function buildBrowserGatewayConfig(params: {
const {
config_output,
browserGatewayResourcesMap,
mtlsCaCertsByResource,
browserGatewayUiUrl,
maintenancePageUiUrl,
badgerMiddlewareName,
@@ -173,7 +177,7 @@ export function buildBrowserGatewayConfig(params: {
const hostRule = `Host(\`${fullDomain}\`)`;
// Build TLS config
const tls = resolveTls({
let tls = resolveTls({
fullDomain,
hasSubdomain: !!bgResource.subdomain,
domainCertResolver: bgResource.domainCertResolver,
@@ -183,6 +187,20 @@ export function buildBrowserGatewayConfig(params: {
continue;
}
// Every TLS router below (including the maintenance ones) must
// reference the same options, since Traefik resolves them per host.
const mtlsOptionsName = bgResource.ssl
? applyMtlsConfig(
config_output,
bgResource.resourceId,
mtlsCaCertsByResource.get(bgResource.resourceId),
routerMiddlewares
)
: undefined;
if (mtlsOptionsName) {
tls = { ...tls, options: mtlsOptionsName };
}
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
if (bgResource.ssl) {
+26 -8
View File
@@ -267,13 +267,6 @@ export async function getTraefikConfig(
});
});
// Trusted client-CA certificates for resources that require mTLS.
const mtlsCaCertsByResource = await getMtlsCaCertificatesByResource(
[...resourcesMap.values()]
.filter((r) => r.mode === "http" && r.ssl)
.map((r) => r.resourceId as number)
);
// Group browser gateway targets by resource (SSH/VNC/RDP-mode resources
// served through the browser gateway web UI instead of a real target).
const browserGatewayResourcesMap = browserGatewayUiUrl
@@ -422,6 +415,18 @@ export async function getTraefikConfig(
});
}
// Trusted client-CA certificates for resources that require mTLS: HTTP,
// browser-gateway (SSH/RDP/VNC) and inference resources served over SSL.
const mtlsCaCertsByResource = await getMtlsCaCertificatesByResource([
...[...resourcesMap.values()]
.filter((r) => r.mode === "http" && r.ssl)
.map((r) => r.resourceId as number),
...[...browserGatewayResourcesMap.values()]
.filter((r) => r.ssl)
.map((r) => r.resourceId),
...inferenceResources.filter((r) => r.ssl).map((r) => r.resourceId)
]);
// make sure we have at least one resource
if (
resourcesMap.size === 0 &&
@@ -650,6 +655,7 @@ export async function getTraefikConfig(
buildBrowserGatewayConfig({
config_output,
browserGatewayResourcesMap,
mtlsCaCertsByResource,
browserGatewayUiUrl,
maintenancePageUiUrl,
badgerMiddlewareName,
@@ -757,7 +763,7 @@ export async function getTraefikConfig(
const rule = buildHostRule(fullDomain, ir.wildcard);
const tls = buildWildcardTls({
let tls: Record<string, any> = buildWildcardTls({
fullDomain,
hasSubdomain: !!ir.subdomain,
domainCertResolver: ir.domainCertResolver,
@@ -777,6 +783,18 @@ export async function getTraefikConfig(
...additionalMiddlewares
];
const mtlsOptionsName = ir.ssl
? applyMtlsConfig(
config_output,
ir.resourceId,
mtlsCaCertsByResource.get(ir.resourceId),
routerMiddlewares
)
: undefined;
if (mtlsOptionsName) {
tls = { ...tls, options: mtlsOptionsName };
}
const { routers, services } = buildAiGatewayRouterAndService({
routerName,
serviceName,
+25 -7
View File
@@ -326,13 +326,6 @@ export async function getTraefikConfig(
});
}
// Trusted client-CA certificates for resources that require mTLS.
const mtlsCaCertsByResource = await getMtlsCaCertificatesByResource(
[...resourcesMap.values()]
.filter((r) => r.mode === "http" && r.ssl)
.map((r) => r.resourceId)
);
// Group browser gateway targets by resource
const browserGatewayResourcesMap = browserGatewayUiUrl
? buildBrowserGatewayResourcesMap(
@@ -553,6 +546,18 @@ export async function getTraefikConfig(
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
}
// Trusted client-CA certificates for resources that require mTLS: HTTP,
// browser-gateway (SSH/RDP/VNC) and inference resources served over SSL.
const mtlsCaCertsByResource = await getMtlsCaCertificatesByResource([
...[...resourcesMap.values()]
.filter((r) => r.mode === "http" && r.ssl)
.map((r) => r.resourceId as number),
...[...browserGatewayResourcesMap.values()]
.filter((r) => r.ssl)
.map((r) => r.resourceId),
...inferenceResources.filter((r) => r.ssl).map((r) => r.resourceId)
]);
const traefikRateLimit = config.getRawConfig().traefik.rate_limit;
const config_output: any = {
@@ -944,6 +949,7 @@ export async function getTraefikConfig(
buildBrowserGatewayConfig({
config_output,
browserGatewayResourcesMap,
mtlsCaCertsByResource,
browserGatewayUiUrl,
maintenancePageUiUrl,
badgerMiddlewareName,
@@ -1124,6 +1130,18 @@ export async function getTraefikConfig(
routerMiddlewares.push(...additionalMiddlewares);
const mtlsOptionsName = ir.ssl
? applyMtlsConfig(
config_output,
ir.resourceId,
mtlsCaCertsByResource.get(ir.resourceId),
routerMiddlewares
)
: undefined;
if (mtlsOptionsName) {
tls = { ...tls, options: mtlsOptionsName };
}
const { routers, services } = buildAiGatewayRouterAndService({
routerName,
serviceName,
@@ -124,7 +124,7 @@ const createResourcePolicyBodySchema = z.strictObject({
.optional()
.default(false)
.describe(
"Require clients to present a certificate signed by one of mtlsCaCertificates. Only takes effect on HTTP resources with SSL enabled."
"Require clients to present a certificate signed by one of mtlsCaCertificates. Only takes effect on resources with SSL enabled (not TCP/UDP)."
),
mtlsCaCertificates: z
.array(
@@ -21,7 +21,7 @@ const updateResourcePolicyBodySchema = z.strictObject({
.boolean()
.optional()
.describe(
"Require clients to present a certificate signed by one of the policy's trusted CA certificates. Requires at least one CA certificate, and only takes effect on HTTP resources with SSL enabled."
"Require clients to present a certificate signed by one of the policy's trusted CA certificates. Requires at least one CA certificate, and only takes effect on resources with SSL enabled (not TCP/UDP)."
)
});
@@ -9,6 +9,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { addMtlsCertificates, getResourceMtlsOwner } from "@server/lib/mtls";
import { resourceModeSupportsMtls } from "@server/lib/mtlsModes";
import { CreateResourceMtlsCertificateResponse } from "@server/routers/resource/types";
const createResourceMtlsCertificateParamsSchema = z.strictObject({
@@ -106,11 +107,11 @@ export async function createResourceMtlsCertificate(
);
}
if (resource.mode !== "http") {
if (!resourceModeSupportsMtls(resource.mode)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"mTLS is only supported on HTTP resources"
"mTLS is not supported on TCP/UDP resources"
)
);
}
+3 -2
View File
@@ -54,6 +54,7 @@ import {
getResourceMtlsOwner,
isMtlsEnabled
} from "@server/lib/mtls";
import { resourceModeSupportsMtls } from "@server/lib/mtlsModes";
const updateResourceParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
@@ -539,11 +540,11 @@ async function updateHttpResource(
updateData.mtlsEnabled ?? (await isMtlsEnabled(mtlsOwner));
if (updateData.mtlsEnabled === true) {
if (resource.mode !== "http") {
if (!resourceModeSupportsMtls(resource.mode)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"mTLS is only supported on HTTP resources."
"mTLS is not supported on TCP/UDP resources."
)
);
}
@@ -27,6 +27,8 @@ export type PolicyAuthOtherMethodsSectionProps = {
emailCount: number;
emailEnabled: boolean;
disabled?: boolean;
// false shows only mTLS, e.g. for inference resources
credentialMethods?: boolean;
onConfigure: (method: PolicyAuthMethodId) => void;
onTogglePincode: (active: boolean) => void;
onTogglePasscode: (active: boolean) => void;
@@ -51,6 +53,7 @@ export function PolicyAuthOtherMethodsSection({
emailCount,
emailEnabled,
disabled,
credentialMethods = true,
onConfigure,
onTogglePincode,
onTogglePasscode,
@@ -74,55 +77,61 @@ export function PolicyAuthOtherMethodsSection({
</SettingsFormCell>
<SettingsFormCell span="full">
<div className="flex flex-col gap-3">
<PolicyAuthMethodRow
id="pincode"
title={t("policyAuthPincodeTitle")}
description={t("policyAuthPincodeDescription")}
summary={getPincodeSummary({ t })}
active={pinActive}
onConfigure={() => onConfigure("pincode")}
onToggle={onTogglePincode}
disabled={disabled}
/>
{credentialMethods && (
<>
<PolicyAuthMethodRow
id="pincode"
title={t("policyAuthPincodeTitle")}
description={t("policyAuthPincodeDescription")}
summary={getPincodeSummary({ t })}
active={pinActive}
onConfigure={() => onConfigure("pincode")}
onToggle={onTogglePincode}
disabled={disabled}
/>
<PolicyAuthMethodRow
id="passcode"
title={t("policyAuthPasscodeTitle")}
description={t("policyAuthPasscodeDescription")}
summary={getPasscodeSummary({ t })}
active={passcodeActive}
onConfigure={() => onConfigure("passcode")}
onToggle={onTogglePasscode}
disabled={disabled}
/>
<PolicyAuthMethodRow
id="passcode"
title={t("policyAuthPasscodeTitle")}
description={t("policyAuthPasscodeDescription")}
summary={getPasscodeSummary({ t })}
active={passcodeActive}
onConfigure={() => onConfigure("passcode")}
onToggle={onTogglePasscode}
disabled={disabled}
/>
<PolicyAuthMethodRow
id="email"
title={t("policyAuthEmailTitle")}
description={t("policyAuthEmailDescription")}
summary={getEmailWhitelistSummary({
t,
count: emailCount
})}
active={emailWhitelistEnabled}
onConfigure={() => onConfigure("email")}
onToggle={onToggleEmail}
disabled={disabled || !emailEnabled}
/>
<PolicyAuthMethodRow
id="email"
title={t("policyAuthEmailTitle")}
description={t("policyAuthEmailDescription")}
summary={getEmailWhitelistSummary({
t,
count: emailCount
})}
active={emailWhitelistEnabled}
onConfigure={() => onConfigure("email")}
onToggle={onToggleEmail}
disabled={disabled || !emailEnabled}
/>
<PolicyAuthMethodRow
id="header-auth"
title={t("policyAuthHeaderAuthTitle")}
description={t("policyAuthHeaderAuthDescription")}
summary={getHeaderAuthSummary({
t,
headerName: headerAuthUser
})}
active={headerAuthActive}
onConfigure={() => onConfigure("headerAuth")}
onToggle={onToggleHeaderAuth}
disabled={disabled}
/>
<PolicyAuthMethodRow
id="header-auth"
title={t("policyAuthHeaderAuthTitle")}
description={t(
"policyAuthHeaderAuthDescription"
)}
summary={getHeaderAuthSummary({
t,
headerName: headerAuthUser
})}
active={headerAuthActive}
onConfigure={() => onConfigure("headerAuth")}
onToggle={onToggleHeaderAuth}
disabled={disabled}
/>
</>
)}
{mtls && (
<PolicyAuthMethodRow
@@ -427,10 +427,10 @@ export function PolicyAuthStackSectionEdit({
.catch(handleError)
);
policyUpdates.emailWhitelistEnabled = payload.emailWhitelistEnabled;
}
if (canSaveMtls) {
requests.push(mtls.save().catch(handleError));
}
if (canSaveMtls) {
requests.push(mtls.save().catch(handleError));
}
try {
@@ -663,8 +663,9 @@ export function PolicyAuthStackSectionEdit({
}
/>
{!isInferenceResource && (
{(!isInferenceResource || mtls.supported) && (
<PolicyAuthOtherMethodsSection
credentialMethods={!isInferenceResource}
pinActive={pinActive}
passcodeActive={passcodeActive}
emailWhitelistEnabled={Boolean(
@@ -813,30 +814,28 @@ export function PolicyAuthStackSectionEdit({
setHeaderAuthActive(true);
}}
/>
{mtls.supported && (
<PolicyMtlsCertificatesCredenza
open={editingMethod === "mtls"}
onOpenChange={(open) => {
if (open) return;
closeCredenza();
// mTLS can't be enabled without a trusted CA.
if (!mtls.hasCertificates) {
mtls.setEnabled(false);
}
}}
resourcePolicyId={
policy.resourcePolicyId
}
certificates={mtls.certificates}
isLoading={mtls.isLoadingCertificates}
onMtlsDisabled={() =>
updatePolicy({ mtlsEnabled: false })
}
/>
)}
</>
)}
{mtls.supported && (
<PolicyMtlsCertificatesCredenza
open={editingMethod === "mtls"}
onOpenChange={(open) => {
if (open) return;
closeCredenza();
// mTLS can't be enabled without a trusted CA.
if (!mtls.hasCertificates) {
mtls.setEnabled(false);
}
}}
resourcePolicyId={policy.resourcePolicyId}
certificates={mtls.certificates}
isLoading={mtls.isLoadingCertificates}
onMtlsDisabled={() =>
updatePolicy({ mtlsEnabled: false })
}
/>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
+3 -2
View File
@@ -3,12 +3,13 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { orgQueries } from "@app/lib/queries";
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
import { resourceModeSupportsMtls } from "@server/lib/mtlsModes";
import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { useContext, useEffect, useState } from "react";
// mTLS is set on the policy being edited, like the other auth methods. When
// editing in the context of a resource it only applies to HTTP resources.
// editing in the context of a resource it doesn't apply to TCP/UDP resources.
export function usePolicyMtls() {
const { policy, updatePolicy } = useResourcePolicyContext();
const resourceContext = useContext(ResourceContext);
@@ -18,7 +19,7 @@ export function usePolicyMtls() {
const supported =
!env.flags.disableEnterpriseFeatures &&
(!resource || resource.mode === "http");
(!resource || resourceModeSupportsMtls(resource.mode));
const { data: certificates = [], isLoading: isLoadingCertificates } =
useQuery({