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
+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()
};
});
}