From 37ed8997f2eda9a2b6f8c9254ceb1eca03d17440 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Sep 2026 16:56:57 -0400 Subject: [PATCH] Add fist pass of mtls support --- messages/en-US.json | 33 ++ server/auth/actions.ts | 3 + server/db/pg/schema/schema.ts | 20 + server/db/sqlite/schema/schema.ts | 27 + server/lib/billing/tierMatrix.ts | 6 +- server/lib/blueprints/MtlsSchema.ts | 3 + server/lib/blueprints/publicResources.ts | 95 ++++ server/lib/blueprints/types.ts | 44 ++ server/lib/mtlsCertificate.ts | 61 +++ server/private/lib/blueprints/MtlsSchema.ts | 19 + .../private/lib/traefik/getTraefikConfig.ts | 87 +++- .../routers/billing/featureLifecycle.ts | 16 + server/routers/external.ts | 22 + server/routers/integration.ts | 33 ++ .../resource/createResourceMtlsCertificate.ts | 239 +++++++++ .../resource/deleteResourceMtlsCertificate.ts | 135 +++++ server/routers/resource/index.ts | 3 + .../resource/listResourceMtlsCertificates.ts | 111 ++++ server/routers/resource/updateResource.ts | 61 ++- .../resources/public/[niceId]/layout.tsx | 7 + .../resources/public/[niceId]/mtls/page.tsx | 490 ++++++++++++++++++ src/lib/queries.ts | 12 + 22 files changed, 1521 insertions(+), 6 deletions(-) create mode 100644 server/lib/blueprints/MtlsSchema.ts create mode 100644 server/lib/mtlsCertificate.ts create mode 100644 server/private/lib/blueprints/MtlsSchema.ts create mode 100644 server/routers/resource/createResourceMtlsCertificate.ts create mode 100644 server/routers/resource/deleteResourceMtlsCertificate.ts create mode 100644 server/routers/resource/listResourceMtlsCertificates.ts create mode 100644 src/app/[orgId]/settings/resources/public/[niceId]/mtls/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index bfb07ef74..fd4ac085f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3892,6 +3892,39 @@ "learnMore": "Learn more", "backToHome": "Go back to home", "needToSignInToOrg": "Need to use your organization's identity provider?", + "mtls": "mTLS", + "mtlsCaCertificates": "Trusted CA certificates", + "mtlsCaCertificatesDescription": "Clients must present a certificate signed by one of these certificate authorities to connect. Upload public CA certificates only, never a private key.", + "mtlsCertificateName": "Name (optional)", + "mtlsCertificateNamePlaceholder": "Corporate device CA", + "mtlsCertificatePem": "CA certificate (PEM)", + "mtlsCertificatePemDescription": "Paste a PEM-encoded certificate or a bundle of certificates, or upload a .pem, .crt or .cer file.", + "mtlsUploadFile": "Upload file", + "mtlsAddCertificate": "Add CA certificate", + "mtlsCertificateAdded": "CA certificate added", + "mtlsCertificateAddedDescription": "Clients with certificates signed by this CA can now be verified.", + "mtlsCertificateErrorAdd": "Failed to add CA certificate", + "mtlsCertificateErrorAddDescription": "The certificate could not be added.", + "mtlsCertificateRemoved": "CA certificate removed", + "mtlsCertificateErrorRemove": "Failed to remove CA certificate", + "mtlsCertificateErrorRemoveDescription": "The certificate could not be removed.", + "mtlsLastCertificateRemoved": "mTLS was turned off because the last CA certificate was removed.", + "mtlsNoCertificates": "No CA certificates added yet", + "mtlsRemoveCertificate": "Remove CA certificate", + "mtlsExpired": "Expired", + "mtlsExpiresSoon": "Expires soon", + "mtlsExpiresOn": "Expires {date}", + "mtlsIssuedBy": "Issued by {issuer}", + "mtlsFingerprint": "SHA-256 fingerprint", + "mtlsEnforcement": "Client certificate enforcement", + "mtlsEnforcementDescription": "Control whether this resource requires a client certificate.", + "enableMtls": "Require client certificate", + "enableMtlsDescription": "Reject connections that don't present a valid certificate from a trusted CA. This is checked during the TLS handshake, before any other authentication method.", + "mtlsRequiresCertificate": "Add at least one CA certificate to enable mTLS.", + "mtlsRequiresSsl": "mTLS requires SSL. Enable SSL in this resource's HTTP settings first.", + "mtlsForwardedHeadersTitle": "Client certificate details are forwarded", + "mtlsForwardedHeadersDescription": "Your backend receives the verified certificate's subject, issuer, serial number and validity in the X-Forwarded-Tls-Client-Cert-Info header.", + "mtlsHandshakeNote": "Clients without a valid certificate fail the TLS handshake, so browsers show a connection error rather than a login page.", "maintenanceMode": "Maintenance Page", "maintenanceModeDescription": "Display a maintenance page to visitors", "maintenanceModeType": "Maintenance Mode Type", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 75caed82d..9af39bd9f 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -81,6 +81,9 @@ export enum ActionsEnum { deleteResourceRule = "deleteResourceRule", listResourceRules = "listResourceRules", updateResourceRule = "updateResourceRule", + createResourceMtlsCertificate = "createResourceMtlsCertificate", + deleteResourceMtlsCertificate = "deleteResourceMtlsCertificate", + listResourceMtlsCertificates = "listResourceMtlsCertificates", createSiteResource = "createSiteResource", deleteSiteResource = "deleteSiteResource", getSiteResource = "getSiteResource", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 145b35756..1c03bf1d6 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -200,6 +200,7 @@ export const resources = pgTable( maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), + mtlsEnabled: boolean("mtlsEnabled").notNull().default(false), postAuthPath: text("postAuthPath"), health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" wildcard: boolean("wildcard").notNull().default(false), @@ -1107,6 +1108,22 @@ export const resourceWhitelist = pgTable("resourceWhitelist", { .references(() => resources.resourceId, { onDelete: "cascade" }) }); +export const resourceMtlsCertificates = pgTable("resourceMtlsCertificates", { + mtlsCertificateId: serial("mtlsCertificateId").primaryKey(), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + name: varchar("name"), + certificate: text("certificate").notNull(), + subject: varchar("subject"), + issuer: varchar("issuer"), + serialNumber: varchar("serialNumber"), + fingerprint: varchar("fingerprint"), + notBefore: bigint("notBefore", { mode: "number" }), + notAfter: bigint("notAfter", { mode: "number" }), + createdAt: bigint("createdAt", { mode: "number" }).notNull() +}); + export const resourceOtp = pgTable("resourceOtp", { otpId: serial("otpId").primaryKey(), resourceId: integer("resourceId") @@ -2019,6 +2036,9 @@ export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel< export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; +export type ResourceMtlsCertificate = InferSelectModel< + typeof resourceMtlsCertificates +>; export type ResourcePolicyPincode = InferSelectModel< typeof resourcePolicyPincode >; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index a72e15556..b479b4af9 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -220,6 +220,9 @@ export const resources = sqliteTable( maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), + mtlsEnabled: integer("mtlsEnabled", { mode: "boolean" }) + .notNull() + .default(false), postAuthPath: text("postAuthPath"), health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" wildcard: integer("wildcard", { mode: "boolean" }) @@ -1377,6 +1380,27 @@ export const resourceWhitelist = sqliteTable("resourceWhitelist", { .references(() => resources.resourceId, { onDelete: "cascade" }) }); +export const resourceMtlsCertificates = sqliteTable( + "resourceMtlsCertificates", + { + mtlsCertificateId: integer("mtlsCertificateId").primaryKey({ + autoIncrement: true + }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + name: text("name"), + certificate: text("certificate").notNull(), + subject: text("subject"), + issuer: text("issuer"), + serialNumber: text("serialNumber"), + fingerprint: text("fingerprint"), + notBefore: integer("notBefore"), + notAfter: integer("notAfter"), + createdAt: integer("createdAt").notNull() + } +); + export const resourceOtp = sqliteTable("resourceOtp", { otpId: integer("otpId").primaryKey({ autoIncrement: true @@ -2062,6 +2086,9 @@ export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel< export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; +export type ResourceMtlsCertificate = InferSelectModel< + typeof resourceMtlsCertificates +>; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index c04a60aed..b87b247ad 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -26,7 +26,8 @@ export enum TierFeature { WildcardSubdomain = "wildcardSubdomain", NewtAutoUpdate = "newtAutoUpdate", ResourcePolicies = "resourcePolicies", - RoleBasedSSHControls = "roleBasedSSHControls" + RoleBasedSSHControls = "roleBasedSSHControls", + Mtls = "mtls" // handle downgrade by disabling mTLS on resources } export const tierMatrix: Record = { @@ -70,5 +71,6 @@ export const tierMatrix: Record = { [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.ResourcePolicies]: ["tier3", "enterprise"], - [TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"] + [TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"], + [TierFeature.Mtls]: ["tier1", "tier2", "tier3", "enterprise"] }; diff --git a/server/lib/blueprints/MtlsSchema.ts b/server/lib/blueprints/MtlsSchema.ts new file mode 100644 index 000000000..5e7054239 --- /dev/null +++ b/server/lib/blueprints/MtlsSchema.ts @@ -0,0 +1,3 @@ +import { z } from "zod"; + +export const MtlsSchema = z.object({}); diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 719671d97..ffd4331df 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -10,6 +10,7 @@ import { Resource, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, + resourceMtlsCertificates, resourcePassword, resourcePincode, resourcePolicies, @@ -62,6 +63,10 @@ import { LimitId } from "../billing"; import { usageService } from "../billing/usageService"; import { syncInferenceAiConfig } from "./aiProviders"; import { syncAiBudgets } from "./aiBudgets"; +import { + MAX_MTLS_CERTIFICATES_PER_RESOURCE, + parseCaCertificates +} from "../mtlsCertificate"; export type PublicResourcesResults = { proxyResource: Resource; @@ -274,6 +279,20 @@ export async function updatePublicResources( ? JSON.stringify(resourceData.responseHeaders) : null; + // mTLS is a security control: refuse (rather than silently drop) a + // blueprint that asks for it on a plan that doesn't include it. + if (resourceData.mtls?.enabled) { + const mtlsLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.mtls + ); + if (!mtlsLicensed) { + throw new Error( + "mTLS is not supported on your current plan. Please upgrade to access this feature." + ); + } + } + if (resourceData.policy) { const isLicensed = await isLicensedOrSubscribed( orgId, @@ -431,6 +450,7 @@ export async function updatePublicResources( resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], + mtlsEnabled: resourceData.mtls?.enabled, proxyProtocol: resourceData.mode === "tcp" ? (resourceData["proxy-protocol"] ?? false) @@ -618,6 +638,7 @@ export async function updatePublicResources( resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], + mtlsEnabled: resourceData.mtls?.enabled, proxyProtocol: resourceData.mode === "tcp" ? (resourceData["proxy-protocol"] ?? false) @@ -722,6 +743,14 @@ export async function updatePublicResources( resourceId: existingResource.resourceId, budgets: resourceData["ai-budget"] || [] }); + + if (resourceData.mtls?.["ca-certificates"]) { + await syncResourceMtlsCertificates( + existingResource.resourceId, + resourceData.mtls["ca-certificates"], + trx + ); + } } const existingResourceTargets = await trx @@ -1246,6 +1275,7 @@ export async function updatePublicResources( maintenanceMessage: resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], + mtlsEnabled: resourceData.mtls?.enabled, proxyProtocol: resourceData.mode === "tcp" ? (resourceData["proxy-protocol"] ?? false) @@ -1300,6 +1330,14 @@ export async function updatePublicResources( budgets: resourceData["ai-budget"] || [] }); + if (resourceData.mtls?.["ca-certificates"]) { + await syncResourceMtlsCertificates( + newResource.resourceId, + resourceData.mtls["ca-certificates"], + trx + ); + } + await trx.insert(roleResources).values({ roleId: adminRole.roleId, resourceId: newResource.resourceId @@ -1643,6 +1681,63 @@ async function syncUserResources( } } +async function syncResourceMtlsCertificates( + resourceId: number, + caCertificates: string[], + trx: Transaction +) { + // A PEM entry may itself be a bundle; flatten and dedupe by fingerprint. + const desired = new Map[0]>(); + for (const pem of caCertificates) { + for (const cert of parseCaCertificates(pem)) { + desired.set(cert.fingerprint, cert); + } + } + + if (desired.size > MAX_MTLS_CERTIFICATES_PER_RESOURCE) { + throw new Error( + `A resource can have at most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates` + ); + } + + const existing = await trx + .select() + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resourceId)); + + const existingFingerprints = new Set(existing.map((c) => c.fingerprint)); + + for (const cert of desired.values()) { + if (existingFingerprints.has(cert.fingerprint)) { + continue; + } + await trx.insert(resourceMtlsCertificates).values({ + resourceId, + certificate: cert.certificate, + subject: cert.subject, + issuer: cert.issuer, + serialNumber: cert.serialNumber, + fingerprint: cert.fingerprint, + notBefore: cert.notBefore, + notAfter: cert.notAfter, + createdAt: Date.now() + }); + } + + for (const row of existing) { + if (!row.fingerprint || !desired.has(row.fingerprint)) { + await trx + .delete(resourceMtlsCertificates) + .where( + eq( + resourceMtlsCertificates.mtlsCertificateId, + row.mtlsCertificateId + ) + ); + } + } +} + async function syncWhitelistUsers( resourceId: number, whitelistUsers: string[], diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index ff3996417..967150ba1 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -2,6 +2,7 @@ import { z } from "zod"; 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 { isValidRegionId } from "@server/db/regions"; import { isValidHttpMethodList } from "@server/lib/validators"; import { wildcardSubdomainSchema } from "@server/lib/schemas"; @@ -331,6 +332,7 @@ export const PublicResourceSchema = z responseHeaders: z.array(HeaderSchema).optional(), rules: z.array(RuleSchema).optional(), maintenance: MaintenanceSchema.optional(), + mtls: MtlsSchema.optional(), "auth-daemon": AuthDaemonSchema.optional(), "proxy-protocol": z.boolean().optional(), "proxy-protocol-version": z.int().min(1).optional(), @@ -445,6 +447,48 @@ export const PublicResourceSchema = z error: "When protocol is 'http', 'ssh', 'rdp', 'vnc', or 'inference', a 'full-domain' must be provided" } ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) { + return true; + } + + return (resource.mode ?? resource.protocol) === "http"; + }, + { + path: ["mtls"], + error: "mTLS is only supported when mode is 'http'" + } + ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) { + return true; + } + + return resource.ssl !== false; + }, + { + path: ["mtls"], + error: "mTLS requires 'ssl' to be enabled" + } + ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) { + return true; + } + + const caCertificates = ( + resource.mtls as { "ca-certificates"?: string[] } + )["ca-certificates"]; + return (caCertificates?.length ?? 0) > 0; + }, + { + path: ["mtls", "ca-certificates"], + error: "mTLS requires at least one entry in 'ca-certificates'" + } + ) .refine( (resource) => { if (isTargetsOnlyResource(resource)) { diff --git a/server/lib/mtlsCertificate.ts b/server/lib/mtlsCertificate.ts new file mode 100644 index 000000000..169d97a87 --- /dev/null +++ b/server/lib/mtlsCertificate.ts @@ -0,0 +1,61 @@ +import { X509Certificate } from "crypto"; + +export const MAX_MTLS_CERTIFICATES_PER_RESOURCE = 20; + +export type ParsedCaCertificate = { + certificate: string; + subject: string; + issuer: string; + serialNumber: string; + fingerprint: string; + notBefore: number; + notAfter: number; +}; + +const PEM_CERTIFICATE_REGEX = + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g; + +// Certificate subjects/issuers come back as newline-separated "CN=foo\nO=bar"; +// flatten to a single readable line for storage and display. +function flattenDn(dn: string): string { + return dn.split("\n").join(", "); +} + +/** + * Parses one or more PEM-encoded CA certificates (a bundle is split into its + * individual certificates). Throws an Error with a user-presentable message + * if the input is not a valid certificate or looks like a private key. + */ +export function parseCaCertificates(input: string): ParsedCaCertificate[] { + if (/PRIVATE KEY/.test(input)) { + throw new Error( + "This looks like a private key, not a certificate. Upload the CA's public certificate only." + ); + } + + const blocks = input.match(PEM_CERTIFICATE_REGEX); + if (!blocks || blocks.length === 0) { + throw new Error( + "Invalid certificate: no PEM-encoded certificate found. Expected a block starting with -----BEGIN CERTIFICATE-----." + ); + } + + return blocks.map((block) => { + let cert: X509Certificate; + try { + cert = new X509Certificate(block); + } catch { + throw new Error("Invalid certificate: could not parse PEM."); + } + + return { + certificate: block.trim() + "\n", + subject: flattenDn(cert.subject), + issuer: flattenDn(cert.issuer), + serialNumber: cert.serialNumber, + fingerprint: cert.fingerprint256, + notBefore: new Date(cert.validFrom).getTime(), + notAfter: new Date(cert.validTo).getTime() + }; + }); +} diff --git a/server/private/lib/blueprints/MtlsSchema.ts b/server/private/lib/blueprints/MtlsSchema.ts new file mode 100644 index 000000000..3ad89cf21 --- /dev/null +++ b/server/private/lib/blueprints/MtlsSchema.ts @@ -0,0 +1,19 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { z } from "zod"; + +export const MtlsSchema = z.object({ + enabled: z.boolean().optional(), + "ca-certificates": z.array(z.string().min(1)).optional() +}); diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index a98368376..4c2b87906 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -18,6 +18,7 @@ import { domains, exitNodes, loginPage, + resourceMtlsCertificates, SiteResource, targetHealthCheck } from "@server/db"; @@ -146,6 +147,8 @@ export async function getTraefikConfig( maintenanceMessage: resources.maintenanceMessage, maintenanceEstimatedTime: resources.maintenanceEstimatedTime, + mtlsEnabled: resources.mtlsEnabled, + // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, @@ -293,7 +296,9 @@ export async function getTraefikConfig( maintenanceModeType: row.maintenanceModeType, maintenanceTitle: row.maintenanceTitle, maintenanceMessage: row.maintenanceMessage, - maintenanceEstimatedTime: row.maintenanceEstimatedTime + maintenanceEstimatedTime: row.maintenanceEstimatedTime, + + mtlsEnabled: row.mtlsEnabled }); } @@ -317,6 +322,31 @@ export async function getTraefikConfig( }); } + // Trusted client-CA certificates for resources that require mTLS. + const mtlsResourceIds = [ + ...new Set( + [...resourcesMap.values()] + .filter((r) => r.mode === "http" && r.mtlsEnabled) + .map((r) => r.resourceId as number) + ) + ]; + const mtlsCaCertsByResource = new Map(); + if (mtlsResourceIds.length > 0) { + const caRows = await db + .select({ + resourceId: resourceMtlsCertificates.resourceId, + certificate: resourceMtlsCertificates.certificate + }) + .from(resourceMtlsCertificates) + .where(inArray(resourceMtlsCertificates.resourceId, mtlsResourceIds)) + .orderBy(resourceMtlsCertificates.mtlsCertificateId); + for (const row of caRows) { + const list = mtlsCaCertsByResource.get(row.resourceId) ?? []; + list.push(row.certificate); + mtlsCaCertsByResource.set(row.resourceId, list); + } + } + // Group browser gateway targets by resource const browserGatewayResourcesMap = browserGatewayUiUrl ? buildBrowserGatewayResourcesMap( @@ -531,7 +561,7 @@ export async function getTraefikConfig( resource.pathMatchType ); - let tls = {}; + let tls: Record = {}; if (!pangolinCertModeEnabled) { tls = buildWildcardTls({ fullDomain, @@ -553,6 +583,59 @@ export async function getTraefikConfig( } } + // mTLS: Traefik resolves TLS options per hostname, and routers on + // the same host that reference different options make it fall back + // to the defaults, so the options are keyed on the resource (not + // the per-path router key) and every TLS router below (including + // the maintenance ones) references the same name. + const mtlsCaCerts = + resource.mtlsEnabled && resource.ssl + ? mtlsCaCertsByResource.get(resource.resourceId) + : undefined; + if (mtlsCaCerts && mtlsCaCerts.length > 0) { + const mtlsOptionsName = `resource-${resource.resourceId}-mtls`; + const mtlsHeadersMiddlewareName = `resource-${resource.resourceId}-mtls-headers`; + + if (!config_output.tls) { + config_output.tls = {}; + } + if (!config_output.tls.options) { + config_output.tls.options = {}; + } + // caFiles entries are Traefik FileOrContent values: anything + // that isn't an existing file path is read as inline PEM, so + // the CA certs travel in this config to any exit node. + config_output.tls.options[mtlsOptionsName] = { + clientAuth: { + caFiles: mtlsCaCerts, + clientAuthType: "RequireAndVerifyClientCert" + } + }; + + config_output.http.middlewares[mtlsHeadersMiddlewareName] = { + passTLSClientCert: { + pem: false, + info: { + notAfter: true, + notBefore: true, + serialNumber: true, + sans: true, + subject: { + commonName: true, + organization: true + }, + issuer: { + commonName: true, + organization: true + } + } + } + }; + routerMiddlewares.unshift(mtlsHeadersMiddlewareName); + + tls = { ...tls, options: mtlsOptionsName }; + } + config_output.http.services![serviceName] = { loadBalancer: { servers: buildHttpLoadBalancerServers(targets), diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index b7487fe49..b26d6afe5 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -303,6 +303,10 @@ async function disableFeature( await disablemaintenancePage(orgId); break; + case TierFeature.Mtls: + await disableMtls(orgId); + break; + case TierFeature.DevicePosture: await disableDevicePosture(orgId); break; @@ -519,6 +523,18 @@ async function disablemaintenancePage(orgId: string): Promise { logger.info(`Disabled maintenance page on all resources for org ${orgId}`); } +// The uploaded CA certificates are kept so mTLS can be re-enabled on upgrade. +async function disableMtls(orgId: string): Promise { + await db + .update(resources) + .set({ + mtlsEnabled: false + }) + .where(eq(resources.orgId, orgId)); + + logger.info(`Disabled mTLS on all resources for org ${orgId}`); +} + async function disableDevicePosture(orgId: string): Promise {} async function disableTwoFactorEnforcement(orgId: string): Promise { diff --git a/server/routers/external.ts b/server/routers/external.ts index 33d1fea6c..432bdefb0 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -834,6 +834,28 @@ authenticated.delete( resource.deleteResourceRule ); +authenticated.put( + "/resource/:resourceId/mtls/certificate", + verifyResourceAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createResourceMtlsCertificate), + logActionAudit(ActionsEnum.createResourceMtlsCertificate), + resource.createResourceMtlsCertificate +); +authenticated.get( + "/resource/:resourceId/mtls/certificates", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceMtlsCertificates), + resource.listResourceMtlsCertificates +); +authenticated.delete( + "/resource/:resourceId/mtls/certificate/:mtlsCertificateId", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.deleteResourceMtlsCertificate), + logActionAudit(ActionsEnum.deleteResourceMtlsCertificate), + resource.deleteResourceMtlsCertificate +); + authenticated.get( "/target/:targetId", verifyTargetAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index bcf5a6a32..a19661002 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -737,6 +737,39 @@ authenticated.delete( resource.deleteResourceRule ); +authenticated.put( + [ + "/resource/:resourceId/mtls/certificate", + "/public-resource/:resourceId/mtls/certificate" + ], + verifyApiKeyResourceAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createResourceMtlsCertificate), + logActionAudit(ActionsEnum.createResourceMtlsCertificate), + resource.createResourceMtlsCertificate +); + +authenticated.get( + [ + "/resource/:resourceId/mtls/certificates", + "/public-resource/:resourceId/mtls/certificates" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceMtlsCertificates), + resource.listResourceMtlsCertificates +); + +authenticated.delete( + [ + "/resource/:resourceId/mtls/certificate/:mtlsCertificateId", + "/public-resource/:resourceId/mtls/certificate/:mtlsCertificateId" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.deleteResourceMtlsCertificate), + logActionAudit(ActionsEnum.deleteResourceMtlsCertificate), + resource.deleteResourceMtlsCertificate +); + authenticated.get( "/target/:targetId", verifyApiKeyTargetAccess, diff --git a/server/routers/resource/createResourceMtlsCertificate.ts b/server/routers/resource/createResourceMtlsCertificate.ts new file mode 100644 index 000000000..e701dd6a3 --- /dev/null +++ b/server/routers/resource/createResourceMtlsCertificate.ts @@ -0,0 +1,239 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourceMtlsCertificates, resources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + MAX_MTLS_CERTIFICATES_PER_RESOURCE, + parseCaCertificates +} from "@server/lib/mtlsCertificate"; + +const createResourceMtlsCertificateParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +const createResourceMtlsCertificateBodySchema = z.strictObject({ + name: z.string().min(1).max(255).optional(), + certificate: z.string().min(1).max(100_000) +}); + +export type CreateResourceMtlsCertificateResponse = { + certificates: { + mtlsCertificateId: number; + resourceId: number; + name: string | null; + subject: string | null; + issuer: string | null; + serialNumber: string | null; + fingerprint: string | null; + notBefore: number | null; + notAfter: number | null; + createdAt: number; + }[]; +}; + +const description = + "Add a trusted CA certificate that clients must present a certificate signed by when mTLS is enabled on a resource. Accepts a PEM bundle; each certificate in the bundle is stored separately."; + +registry.registerPath({ + method: "put", + path: "/resource/{resourceId}/mtls/certificate", + description, + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: createResourceMtlsCertificateParamsSchema, + body: { + content: { + "application/json": { + schema: createResourceMtlsCertificateBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource/{resourceId}/mtls/certificate", + description, + tags: [OpenAPITags.PublicResource], + request: { + params: createResourceMtlsCertificateParamsSchema, + body: { + content: { + "application/json": { + schema: createResourceMtlsCertificateBodySchema + } + } + } + }, + responses: {} +}); + +export async function createResourceMtlsCertificate( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = + createResourceMtlsCertificateParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = createResourceMtlsCertificateBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const { name, certificate } = parsedBody.data; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (resource.mode !== "http") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "mTLS is only supported on HTTP resources" + ) + ); + } + + const isLicensed = await isLicensedOrSubscribed( + resource.orgId, + tierMatrix.mtls + ); + if (!isLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "mTLS is not supported on your current plan. Please upgrade to access this feature." + ) + ); + } + + let parsedCerts; + try { + parsedCerts = parseCaCertificates(certificate); + } catch (e) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + e instanceof Error ? e.message : "Invalid certificate" + ) + ); + } + + const existing = await db + .select({ fingerprint: resourceMtlsCertificates.fingerprint }) + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resourceId)); + + const existingFingerprints = new Set(existing.map((c) => c.fingerprint)); + const newCerts = parsedCerts.filter( + (c) => !existingFingerprints.has(c.fingerprint) + ); + + if (newCerts.length === 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + parsedCerts.length === 1 + ? "This CA certificate has already been added to this resource" + : "All certificates in this bundle have already been added to this resource" + ) + ); + } + + if ( + existing.length + newCerts.length > + MAX_MTLS_CERTIFICATES_PER_RESOURCE + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `A resource can have at most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates` + ) + ); + } + + const now = Date.now(); + const inserted = await db + .insert(resourceMtlsCertificates) + .values( + newCerts.map((c) => ({ + resourceId, + // a label only makes sense for a single certificate + name: newCerts.length === 1 ? (name ?? null) : null, + certificate: c.certificate, + subject: c.subject, + issuer: c.issuer, + serialNumber: c.serialNumber, + fingerprint: c.fingerprint, + notBefore: c.notBefore, + notAfter: c.notAfter, + createdAt: now + })) + ) + .returning({ + mtlsCertificateId: resourceMtlsCertificates.mtlsCertificateId, + resourceId: resourceMtlsCertificates.resourceId, + name: resourceMtlsCertificates.name, + subject: resourceMtlsCertificates.subject, + issuer: resourceMtlsCertificates.issuer, + serialNumber: resourceMtlsCertificates.serialNumber, + fingerprint: resourceMtlsCertificates.fingerprint, + notBefore: resourceMtlsCertificates.notBefore, + notAfter: resourceMtlsCertificates.notAfter, + createdAt: resourceMtlsCertificates.createdAt + }); + + return response(res, { + data: { certificates: inserted }, + success: true, + error: false, + message: "CA certificate added successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/deleteResourceMtlsCertificate.ts b/server/routers/resource/deleteResourceMtlsCertificate.ts new file mode 100644 index 000000000..c4b5ed178 --- /dev/null +++ b/server/routers/resource/deleteResourceMtlsCertificate.ts @@ -0,0 +1,135 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourceMtlsCertificates, resources } from "@server/db"; +import { and, count, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const deleteResourceMtlsCertificateParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive(), + mtlsCertificateId: z.coerce.number().int().positive() +}); + +const description = + "Remove a trusted CA certificate from a resource. If it is the last one, mTLS is disabled on the resource since it can no longer verify any client."; + +registry.registerPath({ + method: "delete", + path: "/resource/{resourceId}/mtls/certificate/{mtlsCertificateId}", + description, + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: deleteResourceMtlsCertificateParamsSchema + }, + responses: {} +}); + +registry.registerPath({ + method: "delete", + path: "/public-resource/{resourceId}/mtls/certificate/{mtlsCertificateId}", + description, + tags: [OpenAPITags.PublicResource], + request: { + params: deleteResourceMtlsCertificateParamsSchema + }, + responses: {} +}); + +export type DeleteResourceMtlsCertificateResponse = { + // true when removing the last CA certificate forced mTLS off + mtlsDisabled: boolean; +}; + +export async function deleteResourceMtlsCertificate( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = + deleteResourceMtlsCertificateParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { resourceId, mtlsCertificateId } = parsedParams.data; + + const [existing] = await db + .select() + .from(resourceMtlsCertificates) + .where( + and( + eq( + resourceMtlsCertificates.mtlsCertificateId, + mtlsCertificateId + ), + eq(resourceMtlsCertificates.resourceId, resourceId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `CA certificate with ID ${mtlsCertificateId} not found` + ) + ); + } + + let mtlsDisabled = false; + await db.transaction(async (trx) => { + await trx + .delete(resourceMtlsCertificates) + .where( + eq( + resourceMtlsCertificates.mtlsCertificateId, + mtlsCertificateId + ) + ); + + const [{ remaining }] = await trx + .select({ remaining: count() }) + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resourceId)); + + // With no CA left there is nothing to verify clients against, so + // leaving mTLS "enabled" would either lock everyone out or be + // silently ignored by the Traefik config builder. + if (remaining === 0) { + const updated = await trx + .update(resources) + .set({ mtlsEnabled: false }) + .where( + and( + eq(resources.resourceId, resourceId), + eq(resources.mtlsEnabled, true) + ) + ) + .returning({ resourceId: resources.resourceId }); + mtlsDisabled = updated.length > 0; + } + }); + + return response(res, { + data: { mtlsDisabled }, + success: true, + error: false, + message: "CA certificate removed successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index a09cf0452..0dcb8099d 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -21,6 +21,9 @@ export * from "./createResourceRule"; export * from "./deleteResourceRule"; export * from "./listResourceRules"; export * from "./updateResourceRule"; +export * from "./createResourceMtlsCertificate"; +export * from "./deleteResourceMtlsCertificate"; +export * from "./listResourceMtlsCertificates"; export * from "./getUserResources"; export * from "./listUserResourceAliases"; export * from "./setResourceHeaderAuth"; diff --git a/server/routers/resource/listResourceMtlsCertificates.ts b/server/routers/resource/listResourceMtlsCertificates.ts new file mode 100644 index 000000000..41ca46430 --- /dev/null +++ b/server/routers/resource/listResourceMtlsCertificates.ts @@ -0,0 +1,111 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourceMtlsCertificates, resources } from "@server/db"; +import { asc, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listResourceMtlsCertificatesParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +function queryResourceMtlsCertificates(resourceId: number) { + return db + .select({ + mtlsCertificateId: resourceMtlsCertificates.mtlsCertificateId, + resourceId: resourceMtlsCertificates.resourceId, + name: resourceMtlsCertificates.name, + subject: resourceMtlsCertificates.subject, + issuer: resourceMtlsCertificates.issuer, + serialNumber: resourceMtlsCertificates.serialNumber, + fingerprint: resourceMtlsCertificates.fingerprint, + notBefore: resourceMtlsCertificates.notBefore, + notAfter: resourceMtlsCertificates.notAfter, + createdAt: resourceMtlsCertificates.createdAt + }) + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resourceId)) + .orderBy(asc(resourceMtlsCertificates.mtlsCertificateId)); +} + +export type ListResourceMtlsCertificatesResponse = { + certificates: Awaited>; +}; + +const description = "List the trusted CA certificates for a resource's mTLS."; + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/mtls/certificates", + description, + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: listResourceMtlsCertificatesParamsSchema + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/mtls/certificates", + description, + tags: [OpenAPITags.PublicResource], + request: { + params: listResourceMtlsCertificatesParamsSchema + }, + responses: {} +}); + +export async function listResourceMtlsCertificates( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = + listResourceMtlsCertificatesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + const certificates = await queryResourceMtlsCertificates(resourceId); + + return response(res, { + data: { certificates }, + success: true, + error: false, + message: "CA certificates retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 22ef89115..2716d8556 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -6,6 +6,7 @@ import { loginPage, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, + resourceMtlsCertificates, resourcePassword, resourcePincode, resourceRules, @@ -24,7 +25,7 @@ import { resourcePolicies, resources } from "@server/db"; -import { eq, and, ne } from "drizzle-orm"; +import { eq, and, ne, count } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -117,6 +118,12 @@ const updateHttpResourceBodySchema = z maintenanceTitle: z.string().max(255).nullable().optional(), maintenanceMessage: z.string().max(2000).nullable().optional(), maintenanceEstimatedTime: z.string().max(100).nullable().optional(), + mtlsEnabled: z + .boolean() + .optional() + .describe( + "Require clients to present a certificate signed by one of the resource's trusted CA certificates. Requires SSL and at least one CA certificate." + ), postAuthPath: z.string().nullable().optional(), // SSH settings pamMode: z.enum(["passthrough", "push"]).optional(), @@ -508,8 +515,58 @@ async function updateHttpResource( } } + const effectiveSsl = updateData.ssl ?? resource.ssl; + const effectiveMtlsEnabled = updateData.mtlsEnabled ?? resource.mtlsEnabled; + + if (updateData.mtlsEnabled === true) { + if (resource.mode !== "http") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "mTLS is only supported on HTTP resources." + ) + ); + } + + const isMtlsLicensed = await isLicensedOrSubscribed( + resource.orgId, + tierMatrix.mtls + ); + if (!isMtlsLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "mTLS is not supported on your current plan. Please upgrade to access this feature." + ) + ); + } + + const [{ certificateCount }] = await db + .select({ certificateCount: count() }) + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resource.resourceId)); + + if (certificateCount === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Add at least one trusted CA certificate before enabling mTLS." + ) + ); + } + } + + if (effectiveMtlsEnabled && !effectiveSsl) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "mTLS requires SSL to be enabled for this resource. Disable mTLS before turning SSL off." + ) + ); + } + // catch when the resource policy changes or gets cleared - if (updateData.resourcePolicyId !== undefined && + if (updateData.resourcePolicyId !== undefined && resource.resourcePolicyId !== updateData.resourcePolicyId) { await clearResourceSpecificSettings( resource.resourceId, diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index b5a36c56c..2bee1be7b 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -111,6 +111,13 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { href: `/{orgId}/settings/resources/public/{niceId}/maintenance` }); } + + if (!env.flags.disableEnterpriseFeatures && resource.mode === "http") { + navItems.push({ + title: t("mtls"), + href: `/{orgId}/settings/resources/public/{niceId}/mtls` + }); + } } if (resource.mode === "inference") { diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/mtls/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/mtls/page.tsx new file mode 100644 index 000000000..da0220e07 --- /dev/null +++ b/src/app/[orgId]/settings/resources/public/[niceId]/mtls/page.tsx @@ -0,0 +1,490 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Badge } from "@app/components/ui/badge"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { useResourceContext } from "@app/hooks/useResourceContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { resourceQueries } from "@app/lib/queries"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { + CreateResourceMtlsCertificateResponse, + DeleteResourceMtlsCertificateResponse, + UpdateResourceResponse +} from "@server/routers/resource"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { AxiosResponse } from "axios"; +import { AlertCircle, FileKey2, Info, ShieldCheck, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const EXPIRES_SOON_MS = 30 * DAY_MS; + +export default function ResourceMtlsPage() { + const params = useParams(); + const router = useRouter(); + const { env } = useEnvContext(); + const { resource, updateResource } = useResourceContext(); + const t = useTranslations(); + const api = createApiClient({ env }); + const queryClient = useQueryClient(); + const { isPaidUser } = usePaidStatus(); + + const supportsMtls = resource.mode === "http"; + const isMtlsDisabled = !isPaidUser(tierMatrix.mtls); + + useEffect(() => { + if (env.flags.disableEnterpriseFeatures || !supportsMtls) { + router.replace( + `/${params.orgId}/settings/resources/public/${resource.niceId}/general` + ); + } + }, [ + env.flags.disableEnterpriseFeatures, + params.orgId, + resource.niceId, + router, + supportsMtls + ]); + + const { data: certificates = [], isLoading: isLoadingCertificates } = + useQuery( + resourceQueries.resourceMtlsCertificates({ + resourceId: resource.resourceId + }) + ); + + const [name, setName] = useState(""); + const [pem, setPem] = useState(""); + const [adding, setAdding] = useState(false); + const [removingId, setRemovingId] = useState(null); + const fileInputRef = useRef(null); + + const [mtlsEnabled, setMtlsEnabled] = useState(resource.mtlsEnabled); + const [saving, setSaving] = useState(false); + + // The server switches mTLS off when the last CA is removed. + useEffect(() => { + setMtlsEnabled(resource.mtlsEnabled); + }, [resource.mtlsEnabled]); + + if (env.flags.disableEnterpriseFeatures || !supportsMtls) { + return null; + } + + const hasCertificates = certificates.length > 0; + const canEnable = hasCertificates && resource.ssl; + + async function invalidateCertificates() { + await queryClient.invalidateQueries( + resourceQueries.resourceMtlsCertificates({ + resourceId: resource.resourceId + }) + ); + } + + async function onFileSelected(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + const text = await file.text(); + setPem((prev) => (prev.trim() ? `${prev.trim()}\n${text}` : text)); + if (!name && certificates.length === 0) { + setName(file.name.replace(/\.(pem|crt|cer|ca-bundle)$/i, "")); + } + } + + async function onAddCertificate() { + if (!pem.trim()) return; + setAdding(true); + try { + await api.put>( + `/resource/${resource.resourceId}/mtls/certificate`, + { + certificate: pem, + ...(name.trim() ? { name: name.trim() } : {}) + } + ); + setPem(""); + setName(""); + await invalidateCertificates(); + toast({ + title: t("mtlsCertificateAdded"), + description: t("mtlsCertificateAddedDescription") + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("mtlsCertificateErrorAdd"), + description: formatAxiosError( + e, + t("mtlsCertificateErrorAddDescription") + ) + }); + } finally { + setAdding(false); + } + } + + async function onRemoveCertificate(mtlsCertificateId: number) { + setRemovingId(mtlsCertificateId); + try { + const res = await api.delete< + AxiosResponse + >( + `/resource/${resource.resourceId}/mtls/certificate/${mtlsCertificateId}` + ); + await invalidateCertificates(); + + if (res.data.data.mtlsDisabled) { + updateResource({ mtlsEnabled: false }); + toast({ + title: t("mtlsCertificateRemoved"), + description: t("mtlsLastCertificateRemoved") + }); + } else { + toast({ title: t("mtlsCertificateRemoved") }); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("mtlsCertificateErrorRemove"), + description: formatAxiosError( + e, + t("mtlsCertificateErrorRemoveDescription") + ) + }); + } finally { + setRemovingId(null); + } + } + + async function onSaveEnforcement() { + setSaving(true); + try { + await api.post>( + `/resource/${resource.resourceId}`, + { mtlsEnabled } + ); + updateResource({ mtlsEnabled }); + toast({ + title: t("resourceUpdated"), + description: t("resourceUpdatedDescription") + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("resourceErrorUpdate"), + description: formatAxiosError( + e, + t("resourceErrorUpdateDescription") + ) + }); + } finally { + setSaving(false); + } + } + + return ( + <> + +
+ + + + + {t("mtlsCaCertificates")} + + + {t("mtlsCaCertificatesDescription")} + + + + +
+ {isLoadingCertificates ? null : hasCertificates ? ( +
    + {certificates.map((cert) => { + const now = Date.now(); + const expired = + cert.notAfter != null && + cert.notAfter < now; + const expiresSoon = + !expired && + cert.notAfter != null && + cert.notAfter - now < + EXPIRES_SOON_MS; + return ( +
  • +
    + +
    +
    + + {cert.name || + cert.subject || + t( + "mtlsCaCertificates" + )} + + {expired && ( + + {t( + "mtlsExpired" + )} + + )} + {expiresSoon && ( + + {t( + "mtlsExpiresSoon" + )} + + )} +
    + {cert.name && + cert.subject && ( +

    + { + cert.subject + } +

    + )} + {cert.issuer && ( +

    + {t( + "mtlsIssuedBy", + { + issuer: cert.issuer + } + )} +

    + )} + {cert.notAfter != + null && ( +

    + {t( + "mtlsExpiresOn", + { + date: new Date( + cert.notAfter + ).toLocaleDateString() + } + )} +

    + )} + {cert.fingerprint && ( +

    + { + cert.fingerprint + } +

    + )} +
    +
    + +
  • + ); + })} +
+ ) : ( +

+ {t("mtlsNoCertificates")} +

+ )} + +
+
+ + + setName(e.target.value) + } + /> +
+
+ +