Add fist pass of mtls support

This commit is contained in:
Owen
2026-09-24 16:56:57 -04:00
parent ed28d34945
commit 37ed8997f2
22 changed files with 1521 additions and 6 deletions
+3
View File
@@ -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",
+20
View File
@@ -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<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type ResourceMtlsCertificate = InferSelectModel<
typeof resourceMtlsCertificates
>;
export type ResourcePolicyPincode = InferSelectModel<
typeof resourcePolicyPincode
>;
+27
View File
@@ -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<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type ResourceMtlsCertificate = InferSelectModel<
typeof resourceMtlsCertificates
>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>;
+4 -2
View File
@@ -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<TierFeature, Tier[]> = {
@@ -70,5 +71,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[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"]
};
+3
View File
@@ -0,0 +1,3 @@
import { z } from "zod";
export const MtlsSchema = z.object({});
+95
View File
@@ -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<string, ReturnType<typeof parseCaCertificates>[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[],
+44
View File
@@ -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)) {
+61
View File
@@ -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()
};
});
}
@@ -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()
});
+85 -2
View File
@@ -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<number, string[]>();
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<string, any> = {};
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),
@@ -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<void> {
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<void> {
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<void> {}
async function disableTwoFactorEnforcement(orgId: string): Promise<void> {
+22
View File
@@ -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,
+33
View File
@@ -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,
@@ -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<any> {
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<CreateResourceMtlsCertificateResponse>(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")
);
}
}
@@ -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<any> {
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<DeleteResourceMtlsCertificateResponse>(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")
);
}
}
+3
View File
@@ -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";
@@ -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<ReturnType<typeof queryResourceMtlsCertificates>>;
};
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<any> {
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<ListResourceMtlsCertificatesResponse>(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")
);
}
}
+59 -2
View File
@@ -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,