Merge branch 'mtls' into dev

This commit is contained in:
Owen
2026-10-12 16:25:25 -04:00
22 changed files with 1521 additions and 6 deletions
@@ -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> {