mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-14 16:49:05 +02:00
Merge branch 'mtls' into dev
This commit is contained in:
@@ -3912,6 +3912,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",
|
||||
|
||||
@@ -84,6 +84,9 @@ export enum ActionsEnum {
|
||||
deleteResourceRule = "deleteResourceRule",
|
||||
listResourceRules = "listResourceRules",
|
||||
updateResourceRule = "updateResourceRule",
|
||||
createResourceMtlsCertificate = "createResourceMtlsCertificate",
|
||||
deleteResourceMtlsCertificate = "deleteResourceMtlsCertificate",
|
||||
listResourceMtlsCertificates = "listResourceMtlsCertificates",
|
||||
createSiteResource = "createSiteResource",
|
||||
deleteSiteResource = "deleteSiteResource",
|
||||
getSiteResource = "getSiteResource",
|
||||
|
||||
@@ -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),
|
||||
@@ -1129,6 +1130,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")
|
||||
@@ -2042,6 +2059,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
|
||||
>;
|
||||
|
||||
@@ -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" })
|
||||
@@ -1399,6 +1402,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
|
||||
@@ -2085,6 +2109,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>;
|
||||
|
||||
@@ -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"]
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const MtlsSchema = z.object({});
|
||||
@@ -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[],
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -848,6 +848,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,
|
||||
|
||||
@@ -738,6 +738,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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
|
||||
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<AxiosResponse<CreateResourceMtlsCertificateResponse>>(
|
||||
`/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<DeleteResourceMtlsCertificateResponse>
|
||||
>(
|
||||
`/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<AxiosResponse<UpdateResourceResponse>>(
|
||||
`/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 (
|
||||
<>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.mtls} />
|
||||
<div
|
||||
className={
|
||||
isMtlsDisabled
|
||||
? "pointer-events-none opacity-50"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("mtlsCaCertificates")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("mtlsCaCertificatesDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<div className="space-y-6">
|
||||
{isLoadingCertificates ? null : hasCertificates ? (
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{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 (
|
||||
<li
|
||||
key={cert.mtlsCertificateId}
|
||||
className="flex items-start justify-between gap-4 p-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<FileKey2 className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{cert.name ||
|
||||
cert.subject ||
|
||||
t(
|
||||
"mtlsCaCertificates"
|
||||
)}
|
||||
</span>
|
||||
{expired && (
|
||||
<Badge variant="red">
|
||||
{t(
|
||||
"mtlsExpired"
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{expiresSoon && (
|
||||
<Badge variant="yellow">
|
||||
{t(
|
||||
"mtlsExpiresSoon"
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{cert.name &&
|
||||
cert.subject && (
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{
|
||||
cert.subject
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{cert.issuer && (
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{t(
|
||||
"mtlsIssuedBy",
|
||||
{
|
||||
issuer: cert.issuer
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{cert.notAfter !=
|
||||
null && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"mtlsExpiresOn",
|
||||
{
|
||||
date: new Date(
|
||||
cert.notAfter
|
||||
).toLocaleDateString()
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{cert.fingerprint && (
|
||||
<p
|
||||
className="truncate font-mono text-xs text-muted-foreground"
|
||||
title={`${t("mtlsFingerprint")}: ${cert.fingerprint}`}
|
||||
>
|
||||
{
|
||||
cert.fingerprint
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={t(
|
||||
"mtlsRemoveCertificate"
|
||||
)}
|
||||
title={t(
|
||||
"mtlsRemoveCertificate"
|
||||
)}
|
||||
loading={
|
||||
removingId ===
|
||||
cert.mtlsCertificateId
|
||||
}
|
||||
disabled={
|
||||
removingId !== null
|
||||
}
|
||||
onClick={() =>
|
||||
onRemoveCertificate(
|
||||
cert.mtlsCertificateId
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("mtlsNoCertificates")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mtls-cert-name">
|
||||
{t("mtlsCertificateName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="mtls-cert-name"
|
||||
value={name}
|
||||
maxLength={255}
|
||||
placeholder={t(
|
||||
"mtlsCertificateNamePlaceholder"
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mtls-cert-pem">
|
||||
{t("mtlsCertificatePem")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="mtls-cert-pem"
|
||||
value={pem}
|
||||
rows={8}
|
||||
spellCheck={false}
|
||||
className="font-mono text-xs"
|
||||
placeholder={
|
||||
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
||||
}
|
||||
onChange={(e) =>
|
||||
setPem(e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("mtlsCertificatePemDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pem,.crt,.cer,.ca-bundle"
|
||||
className="hidden"
|
||||
onChange={onFileSelected}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
>
|
||||
{t("mtlsUploadFile")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
loading={adding}
|
||||
disabled={adding || !pem.trim()}
|
||||
onClick={onAddCertificate}
|
||||
>
|
||||
{t("mtlsAddCertificate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("mtlsEnforcement")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("mtlsEnforcementDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<div className="space-y-4">
|
||||
{!resource.ssl && (
|
||||
<Alert variant="warning">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t("mtlsRequiresSsl")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{resource.ssl && !hasCertificates && (
|
||||
<Alert variant="neutral">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t("mtlsRequiresCertificate")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SwitchInput
|
||||
id="enable-mtls"
|
||||
checked={mtlsEnabled && canEnable}
|
||||
disabled={!canEnable}
|
||||
label={t("enableMtls")}
|
||||
description={t("enableMtlsDescription")}
|
||||
onCheckedChange={setMtlsEnabled}
|
||||
/>
|
||||
|
||||
<Alert variant="info">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t("mtlsForwardedHeadersTitle")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("mtlsForwardedHeadersDescription")}{" "}
|
||||
{t("mtlsHandshakeNote")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="button"
|
||||
loading={saving}
|
||||
disabled={
|
||||
saving ||
|
||||
(mtlsEnabled && canEnable) ===
|
||||
resource.mtlsEnabled
|
||||
}
|
||||
onClick={onSaveEnforcement}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import type {
|
||||
GetResourcePoliciesResponse,
|
||||
GetResourceWhitelistResponse,
|
||||
ListResourceAiModelsResponse,
|
||||
ListResourceMtlsCertificatesResponse,
|
||||
ListResourceNamesResponse,
|
||||
ListResourceRolesResponse,
|
||||
ListResourceRulesResponse,
|
||||
@@ -1656,6 +1657,17 @@ export const resourceQueries = {
|
||||
return res.data.data.whitelist;
|
||||
}
|
||||
}),
|
||||
resourceMtlsCertificates: ({ resourceId }: { resourceId: number }) =>
|
||||
queryOptions({
|
||||
queryKey: ["RESOURCES", resourceId, "MTLS_CERTIFICATES"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListResourceMtlsCertificatesResponse>
|
||||
>(`/resource/${resourceId}/mtls/certificates`, { signal });
|
||||
|
||||
return res.data.data.certificates;
|
||||
}
|
||||
}),
|
||||
policies: ({ resourceId }: { resourceId: number }) =>
|
||||
queryOptions({
|
||||
queryKey: ["RESOURCES", resourceId, "POLICIES"] as const,
|
||||
|
||||
Reference in New Issue
Block a user