mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-21 12:09:04 +02:00
MTLS for all public resources
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 ?? "");
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
Reference in New Issue
Block a user