diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 64d6392a7..a61ca6ca4 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -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( diff --git a/server/lib/mtlsModes.ts b/server/lib/mtlsModes.ts new file mode 100644 index 000000000..9067a85c2 --- /dev/null +++ b/server/lib/mtlsModes.ts @@ -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 ?? ""); +} diff --git a/server/lib/traefik/browserGateway.ts b/server/lib/traefik/browserGateway.ts index 98a6690a6..4537bd6cc 100644 --- a/server/lib/traefik/browserGateway.ts +++ b/server/lib/traefik/browserGateway.ts @@ -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; + // trusted client-CA PEMs for resources that require mTLS + mtlsCaCertsByResource: Map; 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) { diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 8721982a4..aadb65b6a 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -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 = 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, diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index af6b8de59..6794078e4 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -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, diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 5f0794f2f..23963562f 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -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( diff --git a/server/routers/policy/updateResourcePolicy.ts b/server/routers/policy/updateResourcePolicy.ts index 9cdf3ca5e..d51d938cd 100644 --- a/server/routers/policy/updateResourcePolicy.ts +++ b/server/routers/policy/updateResourcePolicy.ts @@ -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)." ) }); diff --git a/server/routers/resource/createResourceMtlsCertificate.ts b/server/routers/resource/createResourceMtlsCertificate.ts index bdeff996a..69d26a461 100644 --- a/server/routers/resource/createResourceMtlsCertificate.ts +++ b/server/routers/resource/createResourceMtlsCertificate.ts @@ -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" ) ); } diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 0fa5fbf0b..c78b4bdf9 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -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." ) ); } diff --git a/src/components/resource-policy/PolicyAuthOtherMethodsSection.tsx b/src/components/resource-policy/PolicyAuthOtherMethodsSection.tsx index c0aa4ee84..1204e1fbd 100644 --- a/src/components/resource-policy/PolicyAuthOtherMethodsSection.tsx +++ b/src/components/resource-policy/PolicyAuthOtherMethodsSection.tsx @@ -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({
- onConfigure("pincode")} - onToggle={onTogglePincode} - disabled={disabled} - /> + {credentialMethods && ( + <> + onConfigure("pincode")} + onToggle={onTogglePincode} + disabled={disabled} + /> - onConfigure("passcode")} - onToggle={onTogglePasscode} - disabled={disabled} - /> + onConfigure("passcode")} + onToggle={onTogglePasscode} + disabled={disabled} + /> - onConfigure("email")} - onToggle={onToggleEmail} - disabled={disabled || !emailEnabled} - /> + onConfigure("email")} + onToggle={onToggleEmail} + disabled={disabled || !emailEnabled} + /> - onConfigure("headerAuth")} - onToggle={onToggleHeaderAuth} - disabled={disabled} - /> + onConfigure("headerAuth")} + onToggle={onToggleHeaderAuth} + disabled={disabled} + /> + + )} {mtls && ( - {!isInferenceResource && ( + {(!isInferenceResource || mtls.supported) && ( - - {mtls.supported && ( - { - 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 && ( + { + 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 }) + } + /> + )}