mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-22 12:39:05 +02:00
@@ -11,7 +11,7 @@ experimental:
|
||||
plugins:
|
||||
badger:
|
||||
moduleName: github.com/fosrl/badger
|
||||
version: v1.4.1
|
||||
version: v1.8.0
|
||||
log:
|
||||
level: INFO
|
||||
format: common
|
||||
|
||||
+1
-1
@@ -4488,7 +4488,7 @@
|
||||
"rdpFull": "Fuldt",
|
||||
"rdpReal": "Ekte",
|
||||
"rdpMeta": "Meta",
|
||||
"rdpUploadFiles": "Upload filer",
|
||||
"rdpUploadFiles": "Upload Filer",
|
||||
"rdpFilesReadyToPaste": "Filer klare til at limes ind",
|
||||
"rdpFilesReadyToPasteDescription": "{count} fil(er) kopieret til fjernudklipsholderen — trykk Ctrl+V på fjernskrivebordet for at indsætte.",
|
||||
"rdpUploadFailed": "Uploaden mislykkedes",
|
||||
|
||||
+19
-5
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "There is no limit on the number of users for this host.",
|
||||
"licenseUsersUsedMax": "{usedUsers} of {maxUsers} users used",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# users} one {# user} other {# users}}",
|
||||
"licenseUsageMachines": "Machine Clients",
|
||||
"licenseNoMachineLimit": "There is no limit on the number of machine clients for this host.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} of {maxMachines} machine clients used",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# machine clients} one {# machine client} other {# machine clients}}",
|
||||
"licenseUnlimited": "Unlimited",
|
||||
"licenseTierLabel": "Tier",
|
||||
"licensePurchaseDescription": "Choose how many sites you want to {selectedMode, select, license {purchase a license for. You can always add more sites later.} other {add to your existing license.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "Distribute requests evenly across targets in rotation.",
|
||||
"loadBalancingStrategy_p2c": "Power of Two Choices",
|
||||
"loadBalancingStrategy_p2c_description": "Pick two random targets and send the request to the one with fewer active connections.",
|
||||
"loadBalancingStrategy_hrw": "Client IP Hash",
|
||||
"loadBalancingStrategy_hrw": "Highest Random Weight",
|
||||
"loadBalancingStrategy_hrw_description": "Hash the client IP so the same client is consistently routed to the same target.",
|
||||
"loadBalancingStrategy_leasttime": "Least Response Time",
|
||||
"loadBalancingStrategy_leasttime_description": "Send requests to the target with the fastest response time and fewest active connections.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Save",
|
||||
"proxyAdditional": "Additional Proxy Settings",
|
||||
"proxyAdditionalDescription": "Configure how the resource handles proxy settings",
|
||||
"proxyForwardIdentityHeaders": "Forward Identity Headers",
|
||||
"proxyForwardIdentityHeadersDescription": "Send the authenticated user's identity to the backend in the Remote-User, Remote-Email, Remote-Name, Remote-Role, and Remote-User-Id headers. Disable for backends that reject requests with unrecognized headers.",
|
||||
"proxyCustomHeader": "Custom Host Header",
|
||||
"proxyCustomHeaderDescription": "The host header to set when proxying requests. Leave empty to use the default.",
|
||||
"proxyAdditionalSubmit": "Save Proxy Settings",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
||||
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
||||
"policyAuthHeaderAuthSummary": "Header configured",
|
||||
"policyAuthMtlsTitle": "Client Certificate (mTLS)",
|
||||
"policyAuthMtlsDescription": "Require a client certificate signed by a trusted CA during the TLS handshake",
|
||||
"policyAuthMtlsSummary": "{count} trusted CA certificates",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Set Passcode",
|
||||
@@ -1504,6 +1513,7 @@
|
||||
"actionDeleteSite": "Delete Site",
|
||||
"actionGetSite": "Get Site",
|
||||
"actionListSites": "List Sites",
|
||||
"actionReGenerateSecret": "Regenerate Credentials",
|
||||
"actionCreateAiProvider": "Create AI Provider",
|
||||
"actionDeleteAiProvider": "Delete AI Provider",
|
||||
"actionGetAiProvider": "Get AI Provider",
|
||||
@@ -3938,14 +3948,14 @@
|
||||
"backToHome": "Go back to home",
|
||||
"needToSignInToOrg": "Need to use your organization's identity provider?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Trusted CA certificates",
|
||||
"mtlsCaCertificates": "Set Client 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",
|
||||
"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",
|
||||
@@ -3955,6 +3965,10 @@
|
||||
"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",
|
||||
"mtlsPendingCertificate": "CA certificate {number}",
|
||||
"mtlsCertificateInvalidPem": "No PEM-encoded certificate found. Expected a block starting with -----BEGIN CERTIFICATE-----.",
|
||||
"mtlsCertificatePrivateKey": "This looks like a private key, not a certificate. Upload the CA's public certificate only.",
|
||||
"mtlsCertificateDuplicate": "This CA certificate has already been added.",
|
||||
"mtlsRemoveCertificate": "Remove CA certificate",
|
||||
"mtlsExpired": "Expired",
|
||||
"mtlsExpiresSoon": "Expires soon",
|
||||
@@ -4488,7 +4502,7 @@
|
||||
"rdpFull": "Full",
|
||||
"rdpReal": "Real",
|
||||
"rdpMeta": "Meta",
|
||||
"rdpUploadFiles": "Upload files",
|
||||
"rdpUploadFiles": "Upload Files",
|
||||
"rdpFilesReadyToPaste": "Files ready to paste",
|
||||
"rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.",
|
||||
"rdpUploadFailed": "Upload failed",
|
||||
|
||||
@@ -697,22 +697,6 @@ export const aiSessionLog = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
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 type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -760,6 +744,3 @@ export type AlertEmailRecipients = InferSelectModel<
|
||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
export type ResourceMtlsCertificate = InferSelectModel<
|
||||
typeof resourceMtlsCertificates
|
||||
>;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { InferSelectModel, sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
@@ -205,6 +204,9 @@ export const resources = pgTable(
|
||||
maintenanceMessage: text("maintenanceMessage"),
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
mtlsEnabled: boolean("mtlsEnabled").notNull().default(false),
|
||||
forwardIdentityHeaders: boolean("forwardIdentityHeaders")
|
||||
.notNull()
|
||||
.default(true),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: boolean("wildcard").notNull().default(false),
|
||||
@@ -1245,6 +1247,7 @@ export const resourcePolicies = pgTable(
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
mtlsEnabled: boolean("mtlsEnabled").notNull().default(false),
|
||||
idpId: integer("idpId").references(() => idp.idpId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
@@ -2045,6 +2048,43 @@ export const certificates = pgTable("certificates", {
|
||||
keyFile: text("keyFile")
|
||||
});
|
||||
|
||||
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 resourcePolicyMtlsCertificates = pgTable(
|
||||
"resourcePolicyMtlsCertificates",
|
||||
{
|
||||
mtlsCertificateId: serial("mtlsCertificateId").primaryKey(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
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 type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -2147,3 +2187,9 @@ export type SiteResourceAiProvider = InferSelectModel<
|
||||
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
|
||||
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
export type ResourceMtlsCertificate = InferSelectModel<
|
||||
typeof resourceMtlsCertificates
|
||||
>;
|
||||
export type ResourcePolicyMtlsCertificate = InferSelectModel<
|
||||
typeof resourcePolicyMtlsCertificates
|
||||
>;
|
||||
|
||||
@@ -696,27 +696,6 @@ export const aiSessionLog = sqliteTable(
|
||||
]
|
||||
);
|
||||
|
||||
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 type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -756,6 +735,3 @@ export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
export type ResourceMtlsCertificate = InferSelectModel<
|
||||
typeof resourceMtlsCertificates
|
||||
>;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { InferSelectModel, sql } from "drizzle-orm";
|
||||
import {
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
primaryKey,
|
||||
@@ -227,6 +226,11 @@ export const resources = sqliteTable(
|
||||
mtlsEnabled: integer("mtlsEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
forwardIdentityHeaders: integer("forwardIdentityHeaders", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(true),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: integer("wildcard", { mode: "boolean" })
|
||||
@@ -1553,6 +1557,9 @@ export const resourcePolicies = sqliteTable("resourcePolicies", {
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
mtlsEnabled: integer("mtlsEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
niceId: text("niceId").notNull(),
|
||||
idpId: integer("idpId").references(() => idp.idpId, {
|
||||
onDelete: "set null"
|
||||
@@ -2090,6 +2097,50 @@ export const certificates = sqliteTable("certificates", {
|
||||
keyFile: text("keyFile")
|
||||
});
|
||||
|
||||
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 resourcePolicyMtlsCertificates = sqliteTable(
|
||||
"resourcePolicyMtlsCertificates",
|
||||
{
|
||||
mtlsCertificateId: integer("mtlsCertificateId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
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 type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -2190,3 +2241,9 @@ export type SiteResourceAiProvider = InferSelectModel<
|
||||
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
|
||||
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
export type ResourceMtlsCertificate = InferSelectModel<
|
||||
typeof resourceMtlsCertificates
|
||||
>;
|
||||
export type ResourcePolicyMtlsCertificate = InferSelectModel<
|
||||
typeof resourcePolicyMtlsCertificates
|
||||
>;
|
||||
|
||||
@@ -20,6 +20,7 @@ type EnterpriseEditionKeyGeneratedProps = {
|
||||
personalUseOnly: boolean;
|
||||
users: number;
|
||||
sites: number;
|
||||
machines: number;
|
||||
modifySubscriptionLink?: string;
|
||||
};
|
||||
|
||||
@@ -28,6 +29,7 @@ export const EnterpriseEditionKeyGenerated = ({
|
||||
personalUseOnly,
|
||||
users,
|
||||
sites,
|
||||
machines,
|
||||
modifySubscriptionLink
|
||||
}: EnterpriseEditionKeyGeneratedProps) => {
|
||||
const previewText = personalUseOnly
|
||||
@@ -69,6 +71,10 @@ export const EnterpriseEditionKeyGenerated = ({
|
||||
{
|
||||
label: "Licensed sites",
|
||||
value: sites
|
||||
},
|
||||
{
|
||||
label: "Licensed machine clients",
|
||||
value: machines
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
/**
|
||||
* Builds the timestamp segment of a database backup file name.
|
||||
*
|
||||
* `Date#getMonth` is zero-indexed, so building this inline produced names like
|
||||
* `db_2026-8-12_...` for a backup taken on 12 September 2026. Every field is
|
||||
* also zero-padded, which keeps the names unambiguous and makes them sort
|
||||
* lexicographically in the order they were taken.
|
||||
*
|
||||
* @param date The moment the backup is being taken. Defaults to now.
|
||||
* @returns A timestamp of the form `YYYY-MM-DD_HH-MM-SS`.
|
||||
*/
|
||||
export function formatBackupTimestamp(date: Date = new Date()): string {
|
||||
const pad = (value: number): string => String(value).padStart(2, "0");
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ export enum TierFeature {
|
||||
ActionLogs = "actionLogs", // set the retention period to none on downgrade
|
||||
ConnectionLogs = "connectionLogs",
|
||||
AISessionLogs = "aiSessionLogs",
|
||||
RotateCredentials = "rotateCredentials",
|
||||
MaintenancePage = "maintenancePage", // handle downgrade
|
||||
DevicePosture = "devicePosture",
|
||||
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
|
||||
@@ -26,8 +25,7 @@ export enum TierFeature {
|
||||
WildcardSubdomain = "wildcardSubdomain",
|
||||
NewtAutoUpdate = "newtAutoUpdate",
|
||||
ResourcePolicies = "resourcePolicies",
|
||||
RoleBasedSSHControls = "roleBasedSSHControls",
|
||||
Mtls = "mtls" // handle downgrade by disabling mTLS on resources
|
||||
RoleBasedSSHControls = "roleBasedSSHControls"
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -40,7 +38,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.AISessionLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.MaintenancePage]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.TwoFactorEnforcement]: [
|
||||
@@ -71,6 +68,5 @@ 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.Mtls]: ["tier3", "enterprise"]
|
||||
[TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"]
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
users
|
||||
} from "@server/db";
|
||||
import { queryResourcePolicy } from "@server/routers/policy/getResourcePolicy";
|
||||
import { getMtlsCertificatePems, type MtlsOwner } from "@server/lib/mtls";
|
||||
import {
|
||||
listPublicResourceAiProviders,
|
||||
listSiteResourceAiProviders
|
||||
@@ -106,12 +107,24 @@ function buildHealthcheck(row: {
|
||||
});
|
||||
}
|
||||
|
||||
async function buildMtlsConfig(owner: MtlsOwner, enabled: boolean) {
|
||||
const caCertificates = await getMtlsCertificatePems(owner);
|
||||
if (!enabled && caCertificates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return stripUndefined({
|
||||
enabled,
|
||||
"ca-certificates": caCertificates.length ? caCertificates : undefined
|
||||
});
|
||||
}
|
||||
|
||||
async function buildPublicAuthAndRules(resource: {
|
||||
resourceId: number;
|
||||
resourcePolicyId: number | null;
|
||||
defaultResourcePolicyId: number | null;
|
||||
sso: boolean | null;
|
||||
skipToIdpId: number | null;
|
||||
mtlsEnabled: boolean;
|
||||
}): Promise<{
|
||||
policy: string | undefined;
|
||||
auth: Record<string, any> | undefined;
|
||||
@@ -204,7 +217,11 @@ async function buildPublicAuthAndRules(resource: {
|
||||
"whitelist-users": whitelistRows.length
|
||||
? whitelistRows.map((w) => w.email)
|
||||
: undefined,
|
||||
"auto-login-idp": resource.skipToIdpId ?? undefined
|
||||
"auto-login-idp": resource.skipToIdpId ?? undefined,
|
||||
mtls: await buildMtlsConfig(
|
||||
{ type: "resource", resourceId: resource.resourceId },
|
||||
resource.mtlsEnabled
|
||||
)
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -264,7 +281,11 @@ async function buildPublicAuthAndRules(resource: {
|
||||
"whitelist-users": policy.emailWhiteList.length
|
||||
? policy.emailWhiteList.map((w) => w.email)
|
||||
: undefined,
|
||||
"auto-login-idp": policy.idpId ?? undefined
|
||||
"auto-login-idp": policy.idpId ?? undefined,
|
||||
mtls: await buildMtlsConfig(
|
||||
{ type: "policy", resourcePolicyId: policy.resourcePolicyId },
|
||||
policy.mtlsEnabled
|
||||
)
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -423,14 +444,18 @@ export async function buildPublicResourceBlueprintConfig(
|
||||
}
|
||||
if (resource.requestHeaders) {
|
||||
try {
|
||||
resourceConfig.requestHeaders = JSON.parse(resource.requestHeaders);
|
||||
resourceConfig["request-headers"] = JSON.parse(
|
||||
resource.requestHeaders
|
||||
);
|
||||
} catch {
|
||||
// ignore malformed stored headers
|
||||
}
|
||||
}
|
||||
if (resource.responseHeaders) {
|
||||
try {
|
||||
resourceConfig.responseHeaders = JSON.parse(resource.responseHeaders);
|
||||
resourceConfig["response-headers"] = JSON.parse(
|
||||
resource.responseHeaders
|
||||
);
|
||||
} catch {
|
||||
// ignore malformed stored headers
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Resource,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourceMtlsCertificates,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourcePolicies,
|
||||
@@ -56,17 +55,19 @@ import {
|
||||
isValidUrlGlobPattern,
|
||||
parseHttpMethodList
|
||||
} from "../validators";
|
||||
import { Config, isTargetsOnlyResource, TargetData } from "./types";
|
||||
import {
|
||||
Config,
|
||||
getBlueprintResourceMtls,
|
||||
isTargetsOnlyResource,
|
||||
TargetData
|
||||
} from "./types";
|
||||
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
|
||||
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
||||
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";
|
||||
import { clearMtls, syncMtlsCertificates } from "../mtls";
|
||||
|
||||
export type PublicResourcesResults = {
|
||||
proxyResource: Resource;
|
||||
@@ -266,32 +267,25 @@ export async function updatePublicResources(
|
||||
resourceData.ssl == undefined || resourceData.ssl == null
|
||||
? true
|
||||
: resourceData.ssl;
|
||||
// `headers` is a deprecated alias for `requestHeaders`
|
||||
// `headers` and `requestHeaders` are deprecated aliases for
|
||||
// `request-headers`; `responseHeaders` for `response-headers`
|
||||
const mergedRequestHeaders = [
|
||||
...(resourceData.headers ?? []),
|
||||
...(resourceData.requestHeaders ?? [])
|
||||
...(resourceData.requestHeaders ?? []),
|
||||
...(resourceData["request-headers"] ?? [])
|
||||
];
|
||||
const requestHeaders =
|
||||
mergedRequestHeaders.length > 0
|
||||
? JSON.stringify(mergedRequestHeaders)
|
||||
: null;
|
||||
const responseHeaders = resourceData.responseHeaders?.length
|
||||
? 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
const mergedResponseHeaders = [
|
||||
...(resourceData.responseHeaders ?? []),
|
||||
...(resourceData["response-headers"] ?? [])
|
||||
];
|
||||
const responseHeaders =
|
||||
mergedResponseHeaders.length > 0
|
||||
? JSON.stringify(mergedResponseHeaders)
|
||||
: null;
|
||||
|
||||
if (resourceData.policy) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
@@ -422,6 +416,9 @@ export async function updatePublicResources(
|
||||
resourceData.auth?.["auto-login-idp"] || null,
|
||||
ssl: resourceSsl,
|
||||
setHostHeader: resourceData["host-header"] || null,
|
||||
forwardIdentityHeaders:
|
||||
resourceData["forward-identity-headers"] ??
|
||||
true,
|
||||
tlsServerName:
|
||||
resourceData["tls-server-name"] || null,
|
||||
loadBalancingStrategy:
|
||||
@@ -453,7 +450,8 @@ export async function updatePublicResources(
|
||||
resourceData.maintenance?.message,
|
||||
maintenanceEstimatedTime:
|
||||
resourceData.maintenance?.["estimated-time"],
|
||||
mtlsEnabled: resourceData.mtls?.enabled,
|
||||
mtlsEnabled:
|
||||
getBlueprintResourceMtls(resourceData)?.enabled,
|
||||
proxyProtocol:
|
||||
resourceData.mode === "tcp"
|
||||
? (resourceData["proxy-protocol"] ?? false)
|
||||
@@ -590,6 +588,21 @@ export async function updatePublicResources(
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
const mtlsCaCertificates =
|
||||
getBlueprintResourceMtls(resourceData)?.[
|
||||
"ca-certificates"
|
||||
];
|
||||
if (mtlsCaCertificates) {
|
||||
await syncMtlsCertificates(
|
||||
{
|
||||
type: "resource",
|
||||
resourceId: existingResource.resourceId
|
||||
},
|
||||
mtlsCaCertificates,
|
||||
trx
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// INLINE POLICY MODE: ensure inline policy exists
|
||||
const inlinePolicyId = await ensureInlinePolicy(
|
||||
@@ -629,6 +642,9 @@ export async function updatePublicResources(
|
||||
enabled: resourceEnabled,
|
||||
ssl: resourceSsl,
|
||||
setHostHeader: resourceData["host-header"] || null,
|
||||
forwardIdentityHeaders:
|
||||
resourceData["forward-identity-headers"] ??
|
||||
true,
|
||||
tlsServerName:
|
||||
resourceData["tls-server-name"] || null,
|
||||
loadBalancingStrategy:
|
||||
@@ -644,7 +660,6 @@ export async function updatePublicResources(
|
||||
resourceData.maintenance?.message,
|
||||
maintenanceEstimatedTime:
|
||||
resourceData.maintenance?.["estimated-time"],
|
||||
mtlsEnabled: resourceData.mtls?.enabled,
|
||||
proxyProtocol:
|
||||
resourceData.mode === "tcp"
|
||||
? (resourceData["proxy-protocol"] ?? false)
|
||||
@@ -714,7 +729,14 @@ export async function updatePublicResources(
|
||||
resourceWhitelist.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
)
|
||||
),
|
||||
clearMtls(
|
||||
{
|
||||
type: "resource",
|
||||
resourceId: existingResource.resourceId
|
||||
},
|
||||
trx
|
||||
)
|
||||
]);
|
||||
|
||||
// Update inline policy auth fields and policy-level tables
|
||||
@@ -749,14 +771,6 @@ 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
|
||||
@@ -1265,6 +1279,8 @@ export async function updatePublicResources(
|
||||
wildcard: domain ? domain.wildcard : false,
|
||||
enabled: resourceEnabled,
|
||||
setHostHeader: resourceData["host-header"] || null,
|
||||
forwardIdentityHeaders:
|
||||
resourceData["forward-identity-headers"] ?? true,
|
||||
tlsServerName: resourceData["tls-server-name"] || null,
|
||||
loadBalancingStrategy:
|
||||
resourceData["load-balancing-strategy"] ?? "wrr",
|
||||
@@ -1283,7 +1299,6 @@ 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)
|
||||
@@ -1308,7 +1323,10 @@ export async function updatePublicResources(
|
||||
: false,
|
||||
applyRules:
|
||||
resourceData.rules &&
|
||||
resourceData.rules.length > 0
|
||||
resourceData.rules.length > 0,
|
||||
mtlsEnabled:
|
||||
getBlueprintResourceMtls(resourceData)
|
||||
?.enabled
|
||||
}
|
||||
: {})
|
||||
})
|
||||
@@ -1338,14 +1356,6 @@ 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
|
||||
@@ -1434,6 +1444,19 @@ export async function updatePublicResources(
|
||||
);
|
||||
}
|
||||
|
||||
const mtlsCaCertificates =
|
||||
getBlueprintResourceMtls(resourceData)?.["ca-certificates"];
|
||||
if (mtlsCaCertificates) {
|
||||
await syncMtlsCertificates(
|
||||
{
|
||||
type: "resource",
|
||||
resourceId: newResource.resourceId
|
||||
},
|
||||
mtlsCaCertificates,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
// Rules into OLD resourceRules table
|
||||
for (const [index, rule] of resourceData.rules?.entries() ||
|
||||
[]) {
|
||||
@@ -1689,63 +1712,6 @@ 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[],
|
||||
@@ -1832,7 +1798,9 @@ async function syncInlinePolicyAuth(
|
||||
resourceData: any,
|
||||
trx: Transaction
|
||||
) {
|
||||
// Update policy-level SSO/whitelist/applyRules fields
|
||||
const mtls = getBlueprintResourceMtls(resourceData);
|
||||
|
||||
// Update policy-level SSO/whitelist/applyRules/mTLS fields
|
||||
await trx
|
||||
.update(resourcePolicies)
|
||||
.set({
|
||||
@@ -1841,10 +1809,20 @@ async function syncInlinePolicyAuth(
|
||||
emailWhitelistEnabled: resourceData.auth?.["whitelist-users"]
|
||||
? resourceData.auth["whitelist-users"].length > 0
|
||||
: false,
|
||||
applyRules: !!(resourceData.rules && resourceData.rules.length > 0)
|
||||
applyRules: !!(resourceData.rules && resourceData.rules.length > 0),
|
||||
mtlsEnabled: mtls?.enabled
|
||||
})
|
||||
.where(eq(resourcePolicies.resourcePolicyId, policyId));
|
||||
|
||||
// mTLS trusted CAs → resourcePolicyMtlsCertificates
|
||||
if (mtls?.["ca-certificates"]) {
|
||||
await syncMtlsCertificates(
|
||||
{ type: "policy", resourcePolicyId: policyId },
|
||||
mtls["ca-certificates"],
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
// Password
|
||||
await trx
|
||||
.delete(resourcePolicyPassword)
|
||||
@@ -1960,13 +1938,9 @@ async function syncInlinePolicyRules(
|
||||
.update(resourcePolicyRules)
|
||||
.set({
|
||||
action: getRuleAction(rule.action) as
|
||||
| "ACCEPT"
|
||||
| "DROP"
|
||||
| "PASS",
|
||||
"ACCEPT" | "DROP" | "PASS",
|
||||
match: rule.match.toUpperCase() as
|
||||
| "CIDR"
|
||||
| "IP"
|
||||
| "PATH",
|
||||
"CIDR" | "IP" | "PATH",
|
||||
value: getRuleValue(
|
||||
rule.match.toUpperCase(),
|
||||
rule.value
|
||||
@@ -1980,9 +1954,7 @@ async function syncInlinePolicyRules(
|
||||
await trx.insert(resourcePolicyRules).values({
|
||||
resourcePolicyId: policyId,
|
||||
action: getRuleAction(rule.action) as
|
||||
| "ACCEPT"
|
||||
| "DROP"
|
||||
| "PASS",
|
||||
"ACCEPT" | "DROP" | "PASS",
|
||||
match: rule.match.toUpperCase() as "CIDR" | "IP" | "PATH",
|
||||
value: getRuleValue(rule.match.toUpperCase(), rule.value),
|
||||
priority: intendedPriority
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
users
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { Config, ResourcePolicyData } from "./types";
|
||||
import { BlueprintMtls, Config, ResourcePolicyData } from "./types";
|
||||
import logger from "@server/logger";
|
||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
||||
import { syncMtlsCertificates } from "../mtls";
|
||||
|
||||
export type ResourcePoliciesResults = {
|
||||
resourcePolicyId: number;
|
||||
@@ -97,6 +98,8 @@ export async function updateResourcePolicies(
|
||||
}
|
||||
}
|
||||
|
||||
const mtls = policyData.mtls as BlueprintMtls | undefined;
|
||||
|
||||
// Look up the admin role
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
@@ -134,7 +137,9 @@ export async function updateResourcePolicies(
|
||||
policyData["email-whitelist-enabled"] ??
|
||||
policyData["whitelist-users"].length > 0,
|
||||
applyRules:
|
||||
policyData["apply-rules"] || policyData.rules.length > 0
|
||||
policyData["apply-rules"] ||
|
||||
policyData.rules.length > 0,
|
||||
mtlsEnabled: mtls?.enabled
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -247,6 +252,7 @@ export async function updateResourcePolicies(
|
||||
applyRules:
|
||||
policyData["apply-rules"] ||
|
||||
policyData.rules.length > 0,
|
||||
mtlsEnabled: mtls?.enabled ?? false,
|
||||
scope: "global"
|
||||
})
|
||||
.returning();
|
||||
@@ -340,6 +346,14 @@ export async function updateResourcePolicies(
|
||||
);
|
||||
}
|
||||
|
||||
if (mtls?.["ca-certificates"]) {
|
||||
await syncMtlsCertificates(
|
||||
{ type: "policy", resourcePolicyId },
|
||||
mtls["ca-certificates"],
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
results.push({ resourcePolicyId, niceId: policyNiceId });
|
||||
}
|
||||
|
||||
|
||||
+102
-56
@@ -3,6 +3,7 @@ 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 { resourceModeSupportsMtls } from "@server/lib/mtlsModes";
|
||||
import { isValidRegionId } from "@server/db/regions";
|
||||
import { isValidHttpMethodList } from "@server/lib/validators";
|
||||
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||
@@ -123,9 +124,28 @@ export const AuthSchema = z.object({
|
||||
}),
|
||||
"sso-users": z.array(z.string()).optional().default([]),
|
||||
"whitelist-users": z.array(z.email()).optional().default([]),
|
||||
"auto-login-idp": z.int().positive().optional()
|
||||
"auto-login-idp": z.int().positive().optional(),
|
||||
mtls: MtlsSchema.optional()
|
||||
});
|
||||
|
||||
// The shape MtlsSchema has where mTLS is supported; the open-source schema is
|
||||
// empty, so callers read the fields through this type.
|
||||
export type BlueprintMtls = {
|
||||
enabled?: boolean;
|
||||
"ca-certificates"?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A resource's mTLS settings: under 'auth' like the other auth methods, or
|
||||
* the deprecated top-level 'mtls'.
|
||||
*/
|
||||
export function getBlueprintResourceMtls(resource: {
|
||||
auth?: { mtls?: unknown };
|
||||
mtls?: unknown;
|
||||
}): BlueprintMtls | undefined {
|
||||
return (resource.auth?.mtls ?? resource.mtls) as BlueprintMtls | undefined;
|
||||
}
|
||||
|
||||
export const RuleSchema = z
|
||||
.object({
|
||||
action: z.enum(["allow", "deny", "pass"]),
|
||||
@@ -326,16 +346,19 @@ export const PublicResourceSchema = z
|
||||
targets: z.array(TargetSchema.nullable()).optional().default([]),
|
||||
auth: AuthSchema.optional(),
|
||||
"host-header": z.string().optional(),
|
||||
"forward-identity-headers": z.boolean().optional(),
|
||||
"tls-server-name": z.string().optional(),
|
||||
"load-balancing-strategy": z
|
||||
.enum(["wrr", "p2c", "hrw", "leasttime"])
|
||||
.optional(),
|
||||
headers: z.array(HeaderSchema).optional(), // deprecated alias for requestHeaders
|
||||
requestHeaders: z.array(HeaderSchema).optional(),
|
||||
responseHeaders: z.array(HeaderSchema).optional(),
|
||||
"request-headers": z.array(HeaderSchema).optional(),
|
||||
"response-headers": z.array(HeaderSchema).optional(),
|
||||
headers: z.array(HeaderSchema).optional(), // deprecated alias for request-headers
|
||||
requestHeaders: z.array(HeaderSchema).optional(), // deprecated alias for request-headers
|
||||
responseHeaders: z.array(HeaderSchema).optional(), // deprecated alias for response-headers
|
||||
rules: z.array(RuleSchema).optional(),
|
||||
maintenance: MaintenanceSchema.optional(),
|
||||
mtls: MtlsSchema.optional(),
|
||||
mtls: MtlsSchema.optional(), // deprecated alias for auth.mtls
|
||||
"auth-daemon": AuthDaemonSchema.optional(),
|
||||
"proxy-protocol": z.boolean().optional(),
|
||||
"proxy-protocol-version": z.int().min(1).optional(),
|
||||
@@ -452,43 +475,51 @@ export const PublicResourceSchema = z
|
||||
)
|
||||
.refine(
|
||||
(resource) => {
|
||||
if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) {
|
||||
if (
|
||||
isTargetsOnlyResource(resource) ||
|
||||
!getBlueprintResourceMtls(resource)?.enabled
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (resource.mode ?? resource.protocol) === "http";
|
||||
return resourceModeSupportsMtls(resource.mode ?? resource.protocol);
|
||||
},
|
||||
{
|
||||
path: ["mtls"],
|
||||
error: "mTLS is only supported when mode is 'http'"
|
||||
path: ["auth", "mtls"],
|
||||
error: "mTLS is only supported when mode is 'http', 'ssh', 'rdp', 'vnc' or 'inference'"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(resource) => {
|
||||
if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) {
|
||||
if (
|
||||
isTargetsOnlyResource(resource) ||
|
||||
!getBlueprintResourceMtls(resource)?.enabled
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return resource.ssl !== false;
|
||||
},
|
||||
{
|
||||
path: ["mtls"],
|
||||
path: ["auth", "mtls"],
|
||||
error: "mTLS requires 'ssl' to be enabled"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(resource) => {
|
||||
if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) {
|
||||
if (
|
||||
isTargetsOnlyResource(resource) ||
|
||||
!getBlueprintResourceMtls(resource)?.enabled
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const caCertificates = (
|
||||
resource.mtls as { "ca-certificates"?: string[] }
|
||||
)["ca-certificates"];
|
||||
const caCertificates =
|
||||
getBlueprintResourceMtls(resource)?.["ca-certificates"];
|
||||
return (caCertificates?.length ?? 0) > 0;
|
||||
},
|
||||
{
|
||||
path: ["mtls", "ca-certificates"],
|
||||
path: ["auth", "mtls", "ca-certificates"],
|
||||
error: "mTLS requires at least one entry in 'ca-certificates'"
|
||||
}
|
||||
)
|
||||
@@ -828,47 +859,62 @@ export const PrivateResourceSchema = z
|
||||
|
||||
export const ResourcePolicyRuleSchema = RuleSchema;
|
||||
|
||||
export const ResourcePolicySchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
sso: z.boolean().optional().default(true),
|
||||
"auto-login-idp": z.int().positive().optional().nullable(),
|
||||
"sso-roles": z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.default([])
|
||||
.refine((roles) => !roles.includes("Admin"), {
|
||||
error: "Admin role cannot be included in sso-roles"
|
||||
}),
|
||||
"sso-users": z.array(z.string()).optional().default([]),
|
||||
password: z.string().min(4).max(100).optional().nullable(),
|
||||
pincode: z
|
||||
.string()
|
||||
.regex(/^\d{6}$/)
|
||||
.optional()
|
||||
.nullable(),
|
||||
"basic-auth": z
|
||||
.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
"extended-compatibility": z.boolean().default(true)
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
"email-whitelist-enabled": z.boolean().optional().default(false),
|
||||
"whitelist-users": z
|
||||
.array(
|
||||
z.email().or(
|
||||
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
||||
error: "Invalid email address. Wildcard (*) must be the entire local part."
|
||||
})
|
||||
export const ResourcePolicySchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
sso: z.boolean().optional().default(true),
|
||||
"auto-login-idp": z.int().positive().optional().nullable(),
|
||||
"sso-roles": z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.default([])
|
||||
.refine((roles) => !roles.includes("Admin"), {
|
||||
error: "Admin role cannot be included in sso-roles"
|
||||
}),
|
||||
"sso-users": z.array(z.string()).optional().default([]),
|
||||
password: z.string().min(4).max(100).optional().nullable(),
|
||||
pincode: z
|
||||
.string()
|
||||
.regex(/^\d{6}$/)
|
||||
.optional()
|
||||
.nullable(),
|
||||
"basic-auth": z
|
||||
.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
"extended-compatibility": z.boolean().default(true)
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
"email-whitelist-enabled": z.boolean().optional().default(false),
|
||||
"whitelist-users": z
|
||||
.array(
|
||||
z.email().or(
|
||||
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
||||
error: "Invalid email address. Wildcard (*) must be the entire local part."
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
.optional()
|
||||
.default([]),
|
||||
"apply-rules": z.boolean().optional().default(false),
|
||||
rules: z.array(ResourcePolicyRuleSchema).optional().default([])
|
||||
});
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
.optional()
|
||||
.default([]),
|
||||
"apply-rules": z.boolean().optional().default(false),
|
||||
rules: z.array(ResourcePolicyRuleSchema).optional().default([]),
|
||||
mtls: MtlsSchema.optional()
|
||||
})
|
||||
.refine(
|
||||
(policy) => {
|
||||
const mtls = policy.mtls as BlueprintMtls | undefined;
|
||||
if (!mtls?.enabled) {
|
||||
return true;
|
||||
}
|
||||
return (mtls["ca-certificates"]?.length ?? 0) > 0;
|
||||
},
|
||||
{
|
||||
path: ["mtls", "ca-certificates"],
|
||||
error: "mTLS requires at least one entry in 'ca-certificates'"
|
||||
}
|
||||
);
|
||||
export type ResourcePolicyData = z.infer<typeof ResourcePolicySchema>;
|
||||
|
||||
// Schema for the entire configuration object
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import {
|
||||
alias,
|
||||
db,
|
||||
resourceMtlsCertificates,
|
||||
resourcePolicies,
|
||||
resourcePolicyMtlsCertificates,
|
||||
resources,
|
||||
type Resource,
|
||||
type Transaction
|
||||
} from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
eq,
|
||||
inArray,
|
||||
sql,
|
||||
type AnyColumn,
|
||||
type SQL
|
||||
} from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import {
|
||||
MAX_MTLS_CERTIFICATES_PER_RESOURCE,
|
||||
parseCaCertificates,
|
||||
type ParsedCaCertificate
|
||||
} from "./mtlsCertificate";
|
||||
import { MTLS_RESOURCE_MODES } from "./mtlsModes";
|
||||
|
||||
type DbOrTrx = Transaction | typeof db;
|
||||
|
||||
/**
|
||||
* Like the other auth methods (pincode, whitelist, ...), mTLS can be set on a
|
||||
* resource policy or on the resource itself. The resource-level settings only
|
||||
* matter alongside a shared policy; without one the resource's auth lives on
|
||||
* its inline (default) policy.
|
||||
*/
|
||||
export type MtlsOwner =
|
||||
| { type: "resource"; resourceId: number }
|
||||
| { type: "policy"; resourcePolicyId: number };
|
||||
|
||||
export type MtlsCertificateInfo = {
|
||||
mtlsCertificateId: number;
|
||||
name: string | null;
|
||||
subject: string | null;
|
||||
issuer: string | null;
|
||||
serialNumber: string | null;
|
||||
fingerprint: string | null;
|
||||
notBefore: number | null;
|
||||
notAfter: number | null;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type MtlsCertificateRow = MtlsCertificateInfo & { certificate: string };
|
||||
|
||||
/**
|
||||
* Where resource-scoped mTLS writes go, mirroring setResourcePincode: the
|
||||
* inline policy when no shared policy is assigned, otherwise the resource.
|
||||
*/
|
||||
export function getResourceMtlsOwner(
|
||||
resource: Pick<
|
||||
Resource,
|
||||
"resourceId" | "resourcePolicyId" | "defaultResourcePolicyId"
|
||||
>
|
||||
): MtlsOwner {
|
||||
if (
|
||||
resource.resourcePolicyId === null &&
|
||||
resource.defaultResourcePolicyId !== null
|
||||
) {
|
||||
return {
|
||||
type: "policy",
|
||||
resourcePolicyId: resource.defaultResourcePolicyId
|
||||
};
|
||||
}
|
||||
return { type: "resource", resourceId: resource.resourceId };
|
||||
}
|
||||
|
||||
function certificateColumns(
|
||||
table:
|
||||
typeof resourceMtlsCertificates | typeof resourcePolicyMtlsCertificates
|
||||
) {
|
||||
return {
|
||||
mtlsCertificateId: table.mtlsCertificateId,
|
||||
name: table.name,
|
||||
certificate: table.certificate,
|
||||
subject: table.subject,
|
||||
issuer: table.issuer,
|
||||
serialNumber: table.serialNumber,
|
||||
fingerprint: table.fingerprint,
|
||||
notBefore: table.notBefore,
|
||||
notAfter: table.notAfter,
|
||||
createdAt: table.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
function toInfo({ certificate: _, ...info }: MtlsCertificateRow) {
|
||||
return info;
|
||||
}
|
||||
|
||||
async function selectCertificates(
|
||||
owner: MtlsOwner,
|
||||
trx: DbOrTrx
|
||||
): Promise<MtlsCertificateRow[]> {
|
||||
if (owner.type === "resource") {
|
||||
return trx
|
||||
.select(certificateColumns(resourceMtlsCertificates))
|
||||
.from(resourceMtlsCertificates)
|
||||
.where(eq(resourceMtlsCertificates.resourceId, owner.resourceId))
|
||||
.orderBy(asc(resourceMtlsCertificates.mtlsCertificateId));
|
||||
}
|
||||
return trx
|
||||
.select(certificateColumns(resourcePolicyMtlsCertificates))
|
||||
.from(resourcePolicyMtlsCertificates)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyMtlsCertificates.resourcePolicyId,
|
||||
owner.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.orderBy(asc(resourcePolicyMtlsCertificates.mtlsCertificateId));
|
||||
}
|
||||
|
||||
async function insertCertificates(
|
||||
owner: MtlsOwner,
|
||||
certs: (ParsedCaCertificate & { name: string | null })[],
|
||||
trx: DbOrTrx
|
||||
): Promise<MtlsCertificateInfo[]> {
|
||||
if (certs.length === 0) return [];
|
||||
const createdAt = Date.now();
|
||||
if (owner.type === "resource") {
|
||||
const rows = await trx
|
||||
.insert(resourceMtlsCertificates)
|
||||
.values(
|
||||
certs.map((c) => ({
|
||||
...c,
|
||||
resourceId: owner.resourceId,
|
||||
createdAt
|
||||
}))
|
||||
)
|
||||
.returning(certificateColumns(resourceMtlsCertificates));
|
||||
return rows.map(toInfo);
|
||||
}
|
||||
const rows = await trx
|
||||
.insert(resourcePolicyMtlsCertificates)
|
||||
.values(
|
||||
certs.map((c) => ({
|
||||
...c,
|
||||
resourcePolicyId: owner.resourcePolicyId,
|
||||
createdAt
|
||||
}))
|
||||
)
|
||||
.returning(certificateColumns(resourcePolicyMtlsCertificates));
|
||||
return rows.map(toInfo);
|
||||
}
|
||||
|
||||
/** Deletes the given certificates, or all of the owner's when ids is omitted. */
|
||||
async function deleteCertificates(
|
||||
owner: MtlsOwner,
|
||||
mtlsCertificateIds: number[] | undefined,
|
||||
trx: DbOrTrx
|
||||
): Promise<number> {
|
||||
if (mtlsCertificateIds?.length === 0) return 0;
|
||||
if (owner.type === "resource") {
|
||||
const deleted = await trx
|
||||
.delete(resourceMtlsCertificates)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceMtlsCertificates.resourceId, owner.resourceId),
|
||||
mtlsCertificateIds
|
||||
? inArray(
|
||||
resourceMtlsCertificates.mtlsCertificateId,
|
||||
mtlsCertificateIds
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
.returning({ id: resourceMtlsCertificates.mtlsCertificateId });
|
||||
return deleted.length;
|
||||
}
|
||||
const deleted = await trx
|
||||
.delete(resourcePolicyMtlsCertificates)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
resourcePolicyMtlsCertificates.resourcePolicyId,
|
||||
owner.resourcePolicyId
|
||||
),
|
||||
mtlsCertificateIds
|
||||
? inArray(
|
||||
resourcePolicyMtlsCertificates.mtlsCertificateId,
|
||||
mtlsCertificateIds
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
.returning({ id: resourcePolicyMtlsCertificates.mtlsCertificateId });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
export async function listMtlsCertificates(
|
||||
owner: MtlsOwner,
|
||||
trx: DbOrTrx = db
|
||||
): Promise<MtlsCertificateInfo[]> {
|
||||
const rows = await selectCertificates(owner, trx);
|
||||
return rows.map(toInfo);
|
||||
}
|
||||
|
||||
/** PEMs of the owner's trusted CAs, e.g. for blueprint export. */
|
||||
export async function getMtlsCertificatePems(
|
||||
owner: MtlsOwner,
|
||||
trx: DbOrTrx = db
|
||||
): Promise<string[]> {
|
||||
const rows = await selectCertificates(owner, trx);
|
||||
return rows.map((r) => r.certificate);
|
||||
}
|
||||
|
||||
export async function countMtlsCertificates(
|
||||
owner: MtlsOwner,
|
||||
trx: DbOrTrx = db
|
||||
): Promise<number> {
|
||||
if (owner.type === "resource") {
|
||||
const [{ total }] = await trx
|
||||
.select({ total: count() })
|
||||
.from(resourceMtlsCertificates)
|
||||
.where(eq(resourceMtlsCertificates.resourceId, owner.resourceId));
|
||||
return total;
|
||||
}
|
||||
const [{ total }] = await trx
|
||||
.select({ total: count() })
|
||||
.from(resourcePolicyMtlsCertificates)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyMtlsCertificates.resourcePolicyId,
|
||||
owner.resourcePolicyId
|
||||
)
|
||||
);
|
||||
return total;
|
||||
}
|
||||
|
||||
export async function isMtlsEnabled(
|
||||
owner: MtlsOwner,
|
||||
trx: DbOrTrx = db
|
||||
): Promise<boolean> {
|
||||
if (owner.type === "resource") {
|
||||
const [row] = await trx
|
||||
.select({ mtlsEnabled: resources.mtlsEnabled })
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, owner.resourceId))
|
||||
.limit(1);
|
||||
return Boolean(row?.mtlsEnabled);
|
||||
}
|
||||
const [row] = await trx
|
||||
.select({ mtlsEnabled: resourcePolicies.mtlsEnabled })
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, owner.resourcePolicyId))
|
||||
.limit(1);
|
||||
return Boolean(row?.mtlsEnabled);
|
||||
}
|
||||
|
||||
/** Returns true if the stored value changed. */
|
||||
export async function setMtlsEnabled(
|
||||
owner: MtlsOwner,
|
||||
enabled: boolean,
|
||||
trx: DbOrTrx = db
|
||||
): Promise<boolean> {
|
||||
if (owner.type === "resource") {
|
||||
const updated = await trx
|
||||
.update(resources)
|
||||
.set({ mtlsEnabled: enabled })
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, owner.resourceId),
|
||||
eq(resources.mtlsEnabled, !enabled)
|
||||
)
|
||||
)
|
||||
.returning({ id: resources.resourceId });
|
||||
return updated.length > 0;
|
||||
}
|
||||
const updated = await trx
|
||||
.update(resourcePolicies)
|
||||
.set({ mtlsEnabled: enabled })
|
||||
.where(
|
||||
and(
|
||||
eq(resourcePolicies.resourcePolicyId, owner.resourcePolicyId),
|
||||
eq(resourcePolicies.mtlsEnabled, !enabled)
|
||||
)
|
||||
)
|
||||
.returning({ id: resourcePolicies.resourcePolicyId });
|
||||
return updated.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a 400 when mTLS is being turned on without a trusted CA, since
|
||||
* there would be nothing to verify clients against.
|
||||
*/
|
||||
export async function assertCanEnableMtls(
|
||||
owner: MtlsOwner,
|
||||
trx: DbOrTrx = db
|
||||
): Promise<void> {
|
||||
if ((await countMtlsCertificates(owner, trx)) === 0) {
|
||||
throw createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Add at least one trusted CA certificate before enabling mTLS."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the certificates in a PEM bundle, skipping ones already present.
|
||||
* Throws an HttpError for invalid input, duplicates, or exceeding the limit.
|
||||
*/
|
||||
export async function addMtlsCertificates(
|
||||
owner: MtlsOwner,
|
||||
pem: string,
|
||||
name: string | undefined,
|
||||
trx: DbOrTrx = db
|
||||
): Promise<MtlsCertificateInfo[]> {
|
||||
let parsedCerts: ParsedCaCertificate[];
|
||||
try {
|
||||
parsedCerts = parseCaCertificates(pem);
|
||||
} catch (e) {
|
||||
throw createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
e instanceof Error ? e.message : "Invalid certificate"
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await selectCertificates(owner, trx);
|
||||
const existingFingerprints = new Set(existing.map((c) => c.fingerprint));
|
||||
const newCerts = parsedCerts.filter(
|
||||
(c) => !existingFingerprints.has(c.fingerprint)
|
||||
);
|
||||
|
||||
if (newCerts.length === 0) {
|
||||
throw createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
parsedCerts.length === 1
|
||||
? "This CA certificate has already been added"
|
||||
: "All certificates in this bundle have already been added"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
existing.length + newCerts.length >
|
||||
MAX_MTLS_CERTIFICATES_PER_RESOURCE
|
||||
) {
|
||||
throw createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`At most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates can be added`
|
||||
);
|
||||
}
|
||||
|
||||
return insertCertificates(
|
||||
owner,
|
||||
newCerts.map((c) => ({
|
||||
...c,
|
||||
// a label only makes sense for a single certificate
|
||||
name: newCerts.length === 1 ? (name ?? null) : null
|
||||
})),
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes one trusted CA. 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; it is switched off instead.
|
||||
* Returns null when the certificate does not belong to the owner.
|
||||
*/
|
||||
export async function removeMtlsCertificate(
|
||||
owner: MtlsOwner,
|
||||
mtlsCertificateId: number
|
||||
): Promise<{ mtlsDisabled: boolean } | null> {
|
||||
return db.transaction(async (trx) => {
|
||||
const removed = await deleteCertificates(
|
||||
owner,
|
||||
[mtlsCertificateId],
|
||||
trx
|
||||
);
|
||||
if (removed === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let mtlsDisabled = false;
|
||||
if ((await countMtlsCertificates(owner, trx)) === 0) {
|
||||
mtlsDisabled = await setMtlsEnabled(owner, false, trx);
|
||||
}
|
||||
return { mtlsDisabled };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the owner's trusted CAs exactly the given PEMs (each entry may be a
|
||||
* bundle). Used by blueprints, which declare the full desired set.
|
||||
*/
|
||||
export async function syncMtlsCertificates(
|
||||
owner: MtlsOwner,
|
||||
caCertificates: string[],
|
||||
trx: Transaction
|
||||
): Promise<void> {
|
||||
// A PEM entry may itself be a bundle; flatten and dedupe by fingerprint.
|
||||
const desired = new Map<string, ParsedCaCertificate>();
|
||||
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(
|
||||
`At most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates can be added`
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await selectCertificates(owner, trx);
|
||||
const existingFingerprints = new Set(existing.map((c) => c.fingerprint));
|
||||
|
||||
await insertCertificates(
|
||||
owner,
|
||||
[...desired.values()]
|
||||
.filter((c) => !existingFingerprints.has(c.fingerprint))
|
||||
.map((c) => ({ ...c, name: null })),
|
||||
trx
|
||||
);
|
||||
|
||||
await deleteCertificates(
|
||||
owner,
|
||||
existing
|
||||
.filter((r) => !r.fingerprint || !desired.has(r.fingerprint))
|
||||
.map((r) => r.mtlsCertificateId),
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
/** Turns mTLS off and drops the trusted CAs. */
|
||||
export async function clearMtls(owner: MtlsOwner, trx: DbOrTrx): Promise<void> {
|
||||
await setMtlsEnabled(owner, false, trx);
|
||||
await deleteCertificates(owner, undefined, trx);
|
||||
}
|
||||
|
||||
type PolicyMtlsColumns = {
|
||||
resourcePolicyId: AnyColumn;
|
||||
mtlsEnabled: AnyColumn;
|
||||
};
|
||||
|
||||
/**
|
||||
* SQL boolean: whether Traefik enforces mTLS on the resource, mirroring
|
||||
* getMtlsCaCertificatesByResource. The shared policy wins over the default
|
||||
* (inline) policy; when that policy has mTLS off the resource-level setting
|
||||
* applies; either way there must be a trusted CA, SSL and a mode served over
|
||||
* HTTP(S). The policies are the caller's joins on resourcePolicyId and
|
||||
* defaultResourcePolicyId.
|
||||
*/
|
||||
export function effectiveMtlsSql(
|
||||
sharedPolicy: PolicyMtlsColumns,
|
||||
defaultPolicy: PolicyMtlsColumns
|
||||
): SQL<boolean> {
|
||||
return sql<boolean>`(CASE
|
||||
WHEN NOT (${resources.ssl} AND ${inArray(resources.mode, [...MTLS_RESOURCE_MODES])}) THEN false
|
||||
WHEN COALESCE(CASE
|
||||
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.mtlsEnabled}
|
||||
ELSE ${defaultPolicy.mtlsEnabled}
|
||||
END, false) THEN EXISTS (
|
||||
SELECT 1 FROM ${resourcePolicyMtlsCertificates}
|
||||
WHERE ${resourcePolicyMtlsCertificates.resourcePolicyId} = COALESCE(${sharedPolicy.resourcePolicyId}, ${defaultPolicy.resourcePolicyId})
|
||||
)
|
||||
WHEN ${resources.mtlsEnabled} THEN EXISTS (
|
||||
SELECT 1 FROM ${resourceMtlsCertificates}
|
||||
WHERE ${resourceMtlsCertificates.resourceId} = ${resources.resourceId}
|
||||
)
|
||||
ELSE false
|
||||
END)`;
|
||||
}
|
||||
|
||||
/** Whether Traefik enforces mTLS on the resource; see effectiveMtlsSql. */
|
||||
export async function isResourceMtlsEnforced(
|
||||
resourceId: number,
|
||||
trx: DbOrTrx = db
|
||||
): Promise<boolean> {
|
||||
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
|
||||
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
|
||||
const [row] = await trx
|
||||
.select({ mtls: effectiveMtlsSql(sharedPolicy, defaultPolicy) })
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
sharedPolicy,
|
||||
eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId)
|
||||
)
|
||||
.leftJoin(
|
||||
defaultPolicy,
|
||||
eq(
|
||||
defaultPolicy.resourcePolicyId,
|
||||
resources.defaultResourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
return Boolean(row?.mtls);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Resource modes that Traefik serves over HTTP(S) - directly, through the
|
||||
// browser gateway (SSH/RDP/VNC) or through the AI gateway (inference) - so a
|
||||
// client certificate can be required during the TLS handshake. Raw TCP/UDP
|
||||
// resources are excluded.
|
||||
export const MTLS_RESOURCE_MODES = [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
] as const;
|
||||
|
||||
export function resourceModeSupportsMtls(
|
||||
mode: string | null | undefined
|
||||
): boolean {
|
||||
return (MTLS_RESOURCE_MODES as readonly string[]).includes(mode ?? "");
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export type OfflineThresholds = {
|
||||
// how often the offline checkers run
|
||||
checkIntervalMs: number;
|
||||
@@ -11,16 +13,14 @@ export type OfflineThresholds = {
|
||||
remoteExitNodeMs: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_OFFLINE_THRESHOLDS: OfflineThresholds = {
|
||||
checkIntervalMs: 30 * 1000, // 30 seconds
|
||||
newtMs: 2 * 60 * 1000, // 2 minutes
|
||||
newtWireguardMs: 8 * 60 * 1000, // 8 minutes
|
||||
olmMs: 2 * 60 * 1000, // 2 minutes
|
||||
remoteExitNodeMs: 2 * 60 * 1000 // 2 minutes
|
||||
};
|
||||
|
||||
// The thresholds are only configurable in the private config file, so the
|
||||
// open build always uses the defaults. See server/private/lib/offlineThresholds.ts
|
||||
export function getOfflineThresholds(): OfflineThresholds {
|
||||
return DEFAULT_OFFLINE_THRESHOLDS;
|
||||
const offlineChecker = config.getRawConfig().offline_checker;
|
||||
|
||||
return {
|
||||
checkIntervalMs: offlineChecker.check_interval_ms,
|
||||
newtMs: offlineChecker.newt_threshold_ms,
|
||||
newtWireguardMs: offlineChecker.newt_wireguard_threshold_ms,
|
||||
olmMs: offlineChecker.olm_threshold_ms,
|
||||
remoteExitNodeMs: offlineChecker.remote_exit_node_threshold_ms
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,16 @@ import { configFilePath1, configFilePath2 } from "./consts";
|
||||
import { z } from "zod";
|
||||
import stoi from "./stoi";
|
||||
import { getEnvOrYaml, readEnvOrFile } from "./getEnvOrYaml";
|
||||
// type-only: offlineThresholds imports config, which imports this file
|
||||
import type { OfflineThresholds } from "@server/lib/offlineThresholds";
|
||||
|
||||
export const DEFAULT_OFFLINE_THRESHOLDS: OfflineThresholds = {
|
||||
checkIntervalMs: 30 * 1000, // 30 seconds
|
||||
newtMs: 2 * 60 * 1000, // 2 minutes
|
||||
newtWireguardMs: 8 * 60 * 1000, // 8 minutes
|
||||
olmMs: 2 * 60 * 1000, // 2 minutes
|
||||
remoteExitNodeMs: 2 * 60 * 1000 // 2 minutes
|
||||
};
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
|
||||
@@ -456,6 +466,36 @@ export const configSchema = z
|
||||
})
|
||||
.prefault({})
|
||||
})
|
||||
.prefault({}),
|
||||
// How long a newt site / olm client / remote exit node has to go
|
||||
// without a ping before the offline checker marks it offline, and how
|
||||
// often the checkers run.
|
||||
offline_checker: z
|
||||
.object({
|
||||
check_interval_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(DEFAULT_OFFLINE_THRESHOLDS.checkIntervalMs),
|
||||
newt_threshold_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.default(DEFAULT_OFFLINE_THRESHOLDS.newtMs),
|
||||
// wireguard sites don't ping, so they are judged by their
|
||||
// last bandwidth update instead
|
||||
newt_wireguard_threshold_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.default(DEFAULT_OFFLINE_THRESHOLDS.newtWireguardMs),
|
||||
olm_threshold_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.default(DEFAULT_OFFLINE_THRESHOLDS.olmMs),
|
||||
remote_exit_node_threshold_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.default(DEFAULT_OFFLINE_THRESHOLDS.remoteExitNodeMs)
|
||||
})
|
||||
.prefault({})
|
||||
})
|
||||
.refine(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import config from "@server/lib/config";
|
||||
import { applyMtlsConfig } from "./mtls";
|
||||
import { sanitize } from "./utils";
|
||||
|
||||
export type BrowserGatewayResourceRow = {
|
||||
@@ -116,6 +117,8 @@ export function buildBrowserGatewayResourcesMap(
|
||||
export function buildBrowserGatewayConfig(params: {
|
||||
config_output: any;
|
||||
browserGatewayResourcesMap: Map<number, BrowserGatewayResourceEntry>;
|
||||
// trusted client-CA PEMs for resources that require mTLS
|
||||
mtlsCaCertsByResource: Map<number, string[]>;
|
||||
browserGatewayUiUrl: string;
|
||||
maintenancePageUiUrl: string | null;
|
||||
badgerMiddlewareName: string;
|
||||
@@ -130,6 +133,7 @@ export function buildBrowserGatewayConfig(params: {
|
||||
const {
|
||||
config_output,
|
||||
browserGatewayResourcesMap,
|
||||
mtlsCaCertsByResource,
|
||||
browserGatewayUiUrl,
|
||||
maintenancePageUiUrl,
|
||||
badgerMiddlewareName,
|
||||
@@ -173,7 +177,7 @@ export function buildBrowserGatewayConfig(params: {
|
||||
const hostRule = `Host(\`${fullDomain}\`)`;
|
||||
|
||||
// Build TLS config
|
||||
const tls = resolveTls({
|
||||
let tls = resolveTls({
|
||||
fullDomain,
|
||||
hasSubdomain: !!bgResource.subdomain,
|
||||
domainCertResolver: bgResource.domainCertResolver,
|
||||
@@ -183,6 +187,20 @@ export function buildBrowserGatewayConfig(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Every TLS router below (including the maintenance ones) must
|
||||
// reference the same options, since Traefik resolves them per host.
|
||||
const mtlsOptionsName = bgResource.ssl
|
||||
? applyMtlsConfig(
|
||||
config_output,
|
||||
bgResource.resourceId,
|
||||
mtlsCaCertsByResource.get(bgResource.resourceId),
|
||||
routerMiddlewares
|
||||
)
|
||||
: undefined;
|
||||
if (mtlsOptionsName) {
|
||||
tls = { ...tls, options: mtlsOptionsName };
|
||||
}
|
||||
|
||||
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
|
||||
|
||||
if (bgResource.ssl) {
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
buildTcpUdpLoadBalancerServers
|
||||
} from "./loadBalancer";
|
||||
import { applyPathRewriteMiddleware } from "./middleware";
|
||||
import { applyMtlsConfig, getMtlsCaCertificatesByResource } from "./mtls";
|
||||
import { buildRedirectConfig, RedirectRouteRow } from "./redirect";
|
||||
import { appendPathMatch, buildHostRule, computeRoutePriority } from "./rule";
|
||||
import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias";
|
||||
@@ -406,6 +407,7 @@ export async function getTraefikConfig(
|
||||
wildcard: row.resourceWildcard,
|
||||
ssl: attachedToResource ? !!row.resourceSsl : row.ssl,
|
||||
attachedTo: attachedToResource ? "resource" : "domain",
|
||||
resourceId: row.resourceId,
|
||||
matchPath: row.matchPath,
|
||||
pathMatchType: row.pathMatchType,
|
||||
priority: row.priority,
|
||||
@@ -414,6 +416,24 @@ export async function getTraefikConfig(
|
||||
});
|
||||
}
|
||||
|
||||
// Trusted client-CA certificates for resources that require mTLS: HTTP,
|
||||
// browser-gateway (SSH/RDP/VNC) and inference resources served over SSL,
|
||||
// and the redirects attached to them.
|
||||
const mtlsCaCertsByResource = await getMtlsCaCertificatesByResource([
|
||||
...[...resourcesMap.values()]
|
||||
.filter((r) => r.mode === "http" && r.ssl)
|
||||
.map((r) => r.resourceId as number),
|
||||
...[...browserGatewayResourcesMap.values()]
|
||||
.filter((r) => r.ssl)
|
||||
.map((r) => r.resourceId),
|
||||
...inferenceResources.filter((r) => r.ssl).map((r) => r.resourceId),
|
||||
// redirects attached to a resource share its host, and are emitted
|
||||
// even on exit nodes that don't serve the resource itself
|
||||
...redirectRoutes
|
||||
.filter((r) => r.attachedTo === "resource" && r.ssl)
|
||||
.map((r) => r.resourceId!)
|
||||
]);
|
||||
|
||||
// make sure we have at least one resource
|
||||
if (
|
||||
resourcesMap.size === 0 &&
|
||||
@@ -465,7 +485,7 @@ export async function getTraefikConfig(
|
||||
config_output.http.services = {};
|
||||
}
|
||||
|
||||
const tls = buildWildcardTls({
|
||||
let tls: Record<string, any> = buildWildcardTls({
|
||||
fullDomain,
|
||||
hasSubdomain: !!resource.subdomain,
|
||||
domainCertResolver: resource.domainCertResolver,
|
||||
@@ -508,6 +528,18 @@ export async function getTraefikConfig(
|
||||
routerMiddlewares.push(headersMiddlewareName);
|
||||
}
|
||||
|
||||
const mtlsOptionsName = resource.ssl
|
||||
? applyMtlsConfig(
|
||||
config_output,
|
||||
resource.resourceId,
|
||||
mtlsCaCertsByResource.get(resource.resourceId),
|
||||
routerMiddlewares
|
||||
)
|
||||
: undefined;
|
||||
if (mtlsOptionsName) {
|
||||
tls = { ...tls, options: mtlsOptionsName };
|
||||
}
|
||||
|
||||
// Build routing rules
|
||||
let rule = buildHostRule(fullDomain);
|
||||
const priority = computeRoutePriority(
|
||||
@@ -614,6 +646,7 @@ export async function getTraefikConfig(
|
||||
buildRedirectConfig({
|
||||
config_output,
|
||||
redirects: redirectRoutes,
|
||||
mtlsCaCertsByResource,
|
||||
badgerMiddlewareName,
|
||||
redirectHttpsMiddlewareName,
|
||||
resolveTls: (redirect) =>
|
||||
@@ -630,6 +663,7 @@ export async function getTraefikConfig(
|
||||
buildBrowserGatewayConfig({
|
||||
config_output,
|
||||
browserGatewayResourcesMap,
|
||||
mtlsCaCertsByResource,
|
||||
browserGatewayUiUrl,
|
||||
maintenancePageUiUrl,
|
||||
badgerMiddlewareName,
|
||||
@@ -737,7 +771,7 @@ export async function getTraefikConfig(
|
||||
|
||||
const rule = buildHostRule(fullDomain, ir.wildcard);
|
||||
|
||||
const tls = buildWildcardTls({
|
||||
let tls: Record<string, any> = buildWildcardTls({
|
||||
fullDomain,
|
||||
hasSubdomain: !!ir.subdomain,
|
||||
domainCertResolver: ir.domainCertResolver,
|
||||
@@ -757,6 +791,18 @@ export async function getTraefikConfig(
|
||||
...additionalMiddlewares
|
||||
];
|
||||
|
||||
const mtlsOptionsName = ir.ssl
|
||||
? applyMtlsConfig(
|
||||
config_output,
|
||||
ir.resourceId,
|
||||
mtlsCaCertsByResource.get(ir.resourceId),
|
||||
routerMiddlewares
|
||||
)
|
||||
: undefined;
|
||||
if (mtlsOptionsName) {
|
||||
tls = { ...tls, options: mtlsOptionsName };
|
||||
}
|
||||
|
||||
const { routers, services } = buildAiGatewayRouterAndService({
|
||||
routerName,
|
||||
serviceName,
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
alias,
|
||||
db,
|
||||
resourceMtlsCertificates,
|
||||
resourcePolicies,
|
||||
resourcePolicyMtlsCertificates,
|
||||
resources
|
||||
} from "@server/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Trusted client-CA PEMs for each resource that requires mTLS, keyed by
|
||||
* resourceId. Resolved like the other auth methods in getResourceByDomain:
|
||||
* the assigned shared policy wins, otherwise the resource's default (inline)
|
||||
* policy; when that policy has mTLS off, the resource-level setting applies.
|
||||
* Resources whose effective mTLS has no CA certificates are left out.
|
||||
*/
|
||||
export async function getMtlsCaCertificatesByResource(
|
||||
resourceIds: number[]
|
||||
): Promise<Map<number, string[]>> {
|
||||
const byResource = new Map<number, string[]>();
|
||||
const uniqueResourceIds = [...new Set(resourceIds)];
|
||||
if (uniqueResourceIds.length === 0) {
|
||||
return byResource;
|
||||
}
|
||||
|
||||
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
|
||||
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
resourceMtlsEnabled: resources.mtlsEnabled,
|
||||
sharedPolicyId: sharedPolicy.resourcePolicyId,
|
||||
sharedPolicyMtlsEnabled: sharedPolicy.mtlsEnabled,
|
||||
defaultPolicyId: defaultPolicy.resourcePolicyId,
|
||||
defaultPolicyMtlsEnabled: defaultPolicy.mtlsEnabled
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
sharedPolicy,
|
||||
eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId)
|
||||
)
|
||||
.leftJoin(
|
||||
defaultPolicy,
|
||||
eq(
|
||||
defaultPolicy.resourcePolicyId,
|
||||
resources.defaultResourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(inArray(resources.resourceId, uniqueResourceIds));
|
||||
|
||||
const resourcesByPolicy = new Map<number, number[]>();
|
||||
const resourceLevelIds: number[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
// If a shared policy is assigned, use only its values and do not
|
||||
// fall back to the default policy.
|
||||
const hasSharedPolicy = row.sharedPolicyId !== null;
|
||||
const policyId = hasSharedPolicy
|
||||
? row.sharedPolicyId
|
||||
: row.defaultPolicyId;
|
||||
const policyMtlsEnabled = hasSharedPolicy
|
||||
? row.sharedPolicyMtlsEnabled
|
||||
: row.defaultPolicyMtlsEnabled;
|
||||
|
||||
if (policyId !== null && policyMtlsEnabled) {
|
||||
const list = resourcesByPolicy.get(policyId) ?? [];
|
||||
list.push(row.resourceId);
|
||||
resourcesByPolicy.set(policyId, list);
|
||||
} else if (row.resourceMtlsEnabled) {
|
||||
resourceLevelIds.push(row.resourceId);
|
||||
}
|
||||
}
|
||||
|
||||
const [policyCerts, resourceCerts] = await Promise.all([
|
||||
resourcesByPolicy.size > 0
|
||||
? db
|
||||
.select({
|
||||
resourcePolicyId:
|
||||
resourcePolicyMtlsCertificates.resourcePolicyId,
|
||||
certificate: resourcePolicyMtlsCertificates.certificate
|
||||
})
|
||||
.from(resourcePolicyMtlsCertificates)
|
||||
.where(
|
||||
inArray(resourcePolicyMtlsCertificates.resourcePolicyId, [
|
||||
...resourcesByPolicy.keys()
|
||||
])
|
||||
)
|
||||
.orderBy(resourcePolicyMtlsCertificates.mtlsCertificateId)
|
||||
: [],
|
||||
resourceLevelIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
resourceId: resourceMtlsCertificates.resourceId,
|
||||
certificate: resourceMtlsCertificates.certificate
|
||||
})
|
||||
.from(resourceMtlsCertificates)
|
||||
.where(
|
||||
inArray(
|
||||
resourceMtlsCertificates.resourceId,
|
||||
resourceLevelIds
|
||||
)
|
||||
)
|
||||
.orderBy(resourceMtlsCertificates.mtlsCertificateId)
|
||||
: []
|
||||
]);
|
||||
|
||||
const append = (resourceId: number, certificate: string) => {
|
||||
const list = byResource.get(resourceId) ?? [];
|
||||
list.push(certificate);
|
||||
byResource.set(resourceId, list);
|
||||
};
|
||||
|
||||
for (const row of policyCerts) {
|
||||
for (const resourceId of resourcesByPolicy.get(row.resourcePolicyId) ??
|
||||
[]) {
|
||||
append(resourceId, row.certificate);
|
||||
}
|
||||
}
|
||||
for (const row of resourceCerts) {
|
||||
append(row.resourceId, row.certificate);
|
||||
}
|
||||
|
||||
return byResource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the TLS options and client-cert headers middleware that enforce mTLS
|
||||
* for a resource and returns the TLS options name for its routers, or
|
||||
* undefined when the resource does not require 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 of the resource must reference the same name.
|
||||
*/
|
||||
export function applyMtlsConfig(
|
||||
config_output: any,
|
||||
resourceId: number,
|
||||
caCerts: string[] | undefined,
|
||||
routerMiddlewares: string[]
|
||||
): string | undefined {
|
||||
if (!caCerts || caCerts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const mtlsOptionsName = `resource-${resourceId}-mtls`;
|
||||
const mtlsHeadersMiddlewareName = `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: caCerts,
|
||||
clientAuthType: "RequireAndVerifyClientCert"
|
||||
}
|
||||
};
|
||||
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
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);
|
||||
|
||||
return mtlsOptionsName;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { applyMtlsConfig } from "@server/lib/traefik/mtls";
|
||||
import {
|
||||
buildHostRule,
|
||||
appendPathMatch,
|
||||
@@ -12,6 +13,8 @@ export type RedirectRouteRow = {
|
||||
fullDomain: string;
|
||||
hasSubdomain: boolean;
|
||||
attachedTo: "resource" | "domain";
|
||||
/** Set when attachedTo is "resource". */
|
||||
resourceId: number | null;
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
wildcard: boolean | null;
|
||||
@@ -35,13 +38,16 @@ export function buildRedirectConfig(params: {
|
||||
badgerMiddlewareName: string;
|
||||
redirectHttpsMiddlewareName: string;
|
||||
resolveTls: (row: RedirectRouteRow) => any | null;
|
||||
// trusted client-CA PEMs for resources that require mTLS
|
||||
mtlsCaCertsByResource: Map<number, string[]>;
|
||||
}): void {
|
||||
const {
|
||||
config_output,
|
||||
redirects,
|
||||
badgerMiddlewareName,
|
||||
redirectHttpsMiddlewareName,
|
||||
resolveTls
|
||||
resolveTls,
|
||||
mtlsCaCertsByResource
|
||||
} = params;
|
||||
|
||||
if (redirects.length === 0) {
|
||||
@@ -72,6 +78,27 @@ export function buildRedirectConfig(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// A redirect attached to a resource listens on the resource's host,
|
||||
// and Traefik resolves TLS options per host: unless it references the
|
||||
// resource's mTLS options too, Traefik falls back to the defaults and
|
||||
// the resource's mTLS is no longer enforced.
|
||||
const redirectMiddlewares = [...routerMiddlewares];
|
||||
if (
|
||||
redirect.ssl &&
|
||||
redirect.attachedTo === "resource" &&
|
||||
redirect.resourceId !== null
|
||||
) {
|
||||
const mtlsOptionsName = applyMtlsConfig(
|
||||
config_output,
|
||||
redirect.resourceId,
|
||||
mtlsCaCertsByResource.get(redirect.resourceId),
|
||||
redirectMiddlewares
|
||||
);
|
||||
if (mtlsOptionsName) {
|
||||
tls = { ...tls, options: mtlsOptionsName };
|
||||
}
|
||||
}
|
||||
|
||||
if (!config_output.http.routers) {
|
||||
config_output.http.routers = {};
|
||||
}
|
||||
@@ -113,7 +140,7 @@ export function buildRedirectConfig(params: {
|
||||
|
||||
config_output.http.routers[routerName] = {
|
||||
entryPoints: [redirect.ssl ? httpsEntrypoint : httpEntrypoint],
|
||||
middlewares: routerMiddlewares,
|
||||
middlewares: redirectMiddlewares,
|
||||
service: NOOP_SERVICE,
|
||||
rule,
|
||||
priority,
|
||||
|
||||
@@ -34,7 +34,6 @@ export type ResourceWithTargets = Pick<
|
||||
| "enabled"
|
||||
| "stickySession"
|
||||
| "loadBalancingStrategy"
|
||||
| "mtlsEnabled"
|
||||
| "tlsServerName"
|
||||
| "setHostHeader"
|
||||
| "enableProxy"
|
||||
|
||||
@@ -16,6 +16,8 @@ export type LicenseStatus = {
|
||||
usedSites?: number;
|
||||
maxUsers?: number;
|
||||
usedUsers?: number;
|
||||
maxMachines?: number;
|
||||
usedMachines?: number;
|
||||
};
|
||||
|
||||
export type LicenseKeyCache = {
|
||||
@@ -28,6 +30,7 @@ export type LicenseKeyCache = {
|
||||
terminateAt?: Date;
|
||||
quantity?: number;
|
||||
quantity_2?: number;
|
||||
quantity_3?: number;
|
||||
};
|
||||
|
||||
export class License {
|
||||
|
||||
+2
-1
@@ -34,7 +34,8 @@ export enum OpenAPITags {
|
||||
AiBudget = "AI Budget",
|
||||
VirtualApiKey = "Virtual API Key",
|
||||
Redirect = "Redirect",
|
||||
Label = "Label"
|
||||
Label = "Label",
|
||||
RemoteExitNode = "Remote Exit Node"
|
||||
}
|
||||
|
||||
// Order here controls the order tags are displayed in Swagger UI
|
||||
|
||||
@@ -18,13 +18,14 @@ import {
|
||||
createLocalCache,
|
||||
type LocalCache
|
||||
} from "@server/lib/createLocalCache";
|
||||
import { eq, and, gt, or, inArray, desc } from "drizzle-orm";
|
||||
import { eq, and, gt, or, inArray, desc, isNull } from "drizzle-orm";
|
||||
import {
|
||||
db,
|
||||
primaryDb,
|
||||
exitNodes,
|
||||
loginPage,
|
||||
newts,
|
||||
redirects,
|
||||
resources,
|
||||
sites,
|
||||
targets,
|
||||
@@ -429,6 +430,28 @@ export class AuthoritativeDNSServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectRecords = await this.getRedirectRecordsByFullDomain(
|
||||
queryName,
|
||||
queryType
|
||||
);
|
||||
|
||||
if (redirectRecords.length > 0) {
|
||||
const answers = this.convertRecordsToAnswers(
|
||||
redirectRecords,
|
||||
originalQueryName,
|
||||
queryType
|
||||
);
|
||||
logger.info(
|
||||
`Returning ${
|
||||
answers.length
|
||||
} redirect answers for ${originalQueryName} (${queryType}): ${JSON.stringify(
|
||||
answers
|
||||
)}`
|
||||
);
|
||||
this.sendResponse(packet, answers, rinfo, true, 0, []);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the domain exists (for NODATA vs NXDOMAIN)
|
||||
const [domainExists, soaRecord] = await Promise.all([
|
||||
this.domainExists(queryName, baseDomain!),
|
||||
@@ -536,6 +559,14 @@ export class AuthoritativeDNSServer {
|
||||
queryName: string,
|
||||
baseDomain: string
|
||||
): Promise<boolean> {
|
||||
// Checked ahead of the `exists:` cache: getResourceRecordsByFullDomain
|
||||
// pre-populates it with false for every name without a resource row,
|
||||
// which includes standalone redirect hosts.
|
||||
const redirectOrgId = await this.getStandaloneRedirectOrgId(
|
||||
queryName
|
||||
).catch(() => null);
|
||||
if (redirectOrgId) return true;
|
||||
|
||||
const cacheKey = `exists:${queryName}`;
|
||||
const cached = this.cache.get<boolean>(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
@@ -700,42 +731,17 @@ export class AuthoritativeDNSServer {
|
||||
logger.info(
|
||||
"No online exit nodes found for resource. Choosing a deterministic exit node."
|
||||
);
|
||||
// TODO: EVENTUALLY WE SHOULD PICK THE CLOSEST EXIT NODE TO THE USER BASED ON LATENCY
|
||||
const orgId = resourceRows[0].orgId;
|
||||
const exitNodesList = await listExitNodes(orgId, true);
|
||||
|
||||
if (!exitNodesList || exitNodesList.length === 0) {
|
||||
logger.warn(`No exit nodes found for orgId: ${orgId}`);
|
||||
this.cache.set(cacheKey, [], 10);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Prefer exit nodes in the same region as this DNS server, if any exist.
|
||||
const dnsRegion = config.getRawConfig().app.region;
|
||||
const regionExitNodes = dnsRegion
|
||||
? exitNodesList.filter(
|
||||
(node) => node.region === dnsRegion
|
||||
)
|
||||
: [];
|
||||
const candidateExitNodes =
|
||||
regionExitNodes.length > 0
|
||||
? regionExitNodes
|
||||
: exitNodesList;
|
||||
|
||||
// Deterministically pick a node based on the queried name so
|
||||
// repeated queries for the same resource keep resolving to the
|
||||
// same exit node instead of changing on every cache miss.
|
||||
const selectedExitNode = this.selectDeterministicExitNode(
|
||||
name,
|
||||
candidateExitNodes
|
||||
);
|
||||
|
||||
const ipRecords = await this.createRecordsFromEndpoint(
|
||||
selectedExitNode.endpoint,
|
||||
const ipRecords = await this.getRecordsFromRegionalExitNode(
|
||||
resourceRows[0].orgId,
|
||||
name,
|
||||
queryType
|
||||
);
|
||||
|
||||
if (ipRecords === null) {
|
||||
this.cache.set(cacheKey, [], 10);
|
||||
return [];
|
||||
}
|
||||
|
||||
this.cache.set(cacheKey, ipRecords);
|
||||
return ipRecords;
|
||||
}
|
||||
@@ -770,6 +776,143 @@ export class AuthoritativeDNSServer {
|
||||
});
|
||||
}
|
||||
|
||||
// Standalone redirects (attached to a domain rather than a resource) have
|
||||
// no targets or sites, and Traefik serves them on every exit node, so any
|
||||
// online exit node in this region can answer for them. Resource-attached
|
||||
// redirects listen on the resource's own fullDomain, so they are already
|
||||
// resolved to the resource's exit nodes by getResourceRecordsByFullDomain.
|
||||
private async getRedirectRecordsByFullDomain(
|
||||
name: string,
|
||||
queryType: dns.RecordType
|
||||
): Promise<DNSRecord[]> {
|
||||
const cacheKey = `redirectRecords:${name}:${queryType}`;
|
||||
|
||||
const cached = this.cache.get<DNSRecord[]>(cacheKey);
|
||||
if (cached) {
|
||||
logger.debug(`Cache hit for ${cacheKey}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
return this.withInFlightDedup<DNSRecord[]>(cacheKey, async () => {
|
||||
logger.debug(`Cache miss for ${cacheKey}, querying database`);
|
||||
|
||||
try {
|
||||
const orgId = await this.getStandaloneRedirectOrgId(name);
|
||||
if (!orgId) {
|
||||
this.cache.set(cacheKey, [], 60);
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await this.getRecordsFromRegionalExitNode(
|
||||
orgId,
|
||||
name,
|
||||
queryType
|
||||
);
|
||||
|
||||
if (results === null) {
|
||||
this.cache.set(cacheKey, [], 10);
|
||||
return [];
|
||||
}
|
||||
|
||||
this.cache.set(cacheKey, results);
|
||||
logger.info(`Cached ${results.length} records for ${cacheKey}`);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("Database query error:", error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Returns the orgId of the enabled standalone redirect listening on
|
||||
// `name`, or null. The host is built the same way as in the Traefik
|
||||
// config: [subdomain.]baseDomain of the redirect's domain.
|
||||
private async getStandaloneRedirectOrgId(
|
||||
name: string
|
||||
): Promise<string | null> {
|
||||
const normalized = name.replace(/\.$/, "");
|
||||
const cacheKey = `redirect:${normalized}`;
|
||||
|
||||
const cached = this.cache.get<string | null>(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return this.withInFlightDedup<string | null>(cacheKey, async () => {
|
||||
// The redirect's baseDomain must be a suffix of the queried name.
|
||||
const labels = normalized.split(".");
|
||||
const candidates = labels.map((_, i) => labels.slice(i).join("."));
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
orgId: redirects.orgId,
|
||||
subdomain: redirects.subdomain,
|
||||
baseDomain: domains.baseDomain
|
||||
})
|
||||
.from(redirects)
|
||||
.innerJoin(domains, eq(domains.domainId, redirects.domainId))
|
||||
.where(
|
||||
and(
|
||||
isNull(redirects.resourceId),
|
||||
eq(redirects.enabled, true),
|
||||
inArray(domains.baseDomain, candidates)
|
||||
)
|
||||
);
|
||||
|
||||
const match = rows.find(
|
||||
(r) =>
|
||||
[r.subdomain, r.baseDomain]
|
||||
.filter(Boolean)
|
||||
.join(".")
|
||||
.toLowerCase() === normalized
|
||||
);
|
||||
|
||||
const orgId = match?.orgId ?? null;
|
||||
this.cache.set(cacheKey, orgId, 60);
|
||||
return orgId;
|
||||
});
|
||||
}
|
||||
|
||||
// Picks one of the org's online exit nodes for `name`, preferring nodes in
|
||||
// the same region as this DNS server, and returns records pointing at it.
|
||||
// Returns null when the org has no online exit nodes at all.
|
||||
private async getRecordsFromRegionalExitNode(
|
||||
orgId: string,
|
||||
name: string,
|
||||
queryType: dns.RecordType
|
||||
): Promise<DNSRecord[] | null> {
|
||||
// TODO: EVENTUALLY WE SHOULD PICK THE CLOSEST EXIT NODE TO THE USER BASED ON LATENCY
|
||||
const exitNodesList = await listExitNodes(orgId, true);
|
||||
|
||||
if (!exitNodesList || exitNodesList.length === 0) {
|
||||
logger.warn(`No exit nodes found for orgId: ${orgId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer exit nodes in the same region as this DNS server, if any exist.
|
||||
const dnsRegion = config.getRawConfig().app.region;
|
||||
const regionExitNodes = dnsRegion
|
||||
? exitNodesList.filter((node) => node.region === dnsRegion)
|
||||
: [];
|
||||
const candidateExitNodes =
|
||||
regionExitNodes.length > 0 ? regionExitNodes : exitNodesList;
|
||||
|
||||
// Deterministically pick a node based on the queried name so repeated
|
||||
// queries for the same name keep resolving to the same exit node
|
||||
// instead of changing on every cache miss.
|
||||
const selectedExitNode = this.selectDeterministicExitNode(
|
||||
name,
|
||||
candidateExitNodes
|
||||
);
|
||||
|
||||
return this.createRecordsFromEndpoint(
|
||||
selectedExitNode.endpoint,
|
||||
name,
|
||||
queryType
|
||||
);
|
||||
}
|
||||
|
||||
// Deterministically picks a node for `name` out of `nodes` by hashing the
|
||||
// queried name, so the same resource keeps resolving to the same exit
|
||||
// node across cache misses and across independently-caching DNS server
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* 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 type { OfflineThresholds } from "@server/lib/offlineThresholds";
|
||||
import privateConfig from "#private/lib/config";
|
||||
|
||||
export function getOfflineThresholds(): OfflineThresholds {
|
||||
const offlineChecker = privateConfig.getRawPrivateConfig().offline_checker;
|
||||
|
||||
return {
|
||||
checkIntervalMs: offlineChecker.check_interval_ms,
|
||||
newtMs: offlineChecker.newt_threshold_ms,
|
||||
newtWireguardMs: offlineChecker.newt_wireguard_threshold_ms,
|
||||
olmMs: offlineChecker.olm_threshold_ms,
|
||||
remoteExitNodeMs: offlineChecker.remote_exit_node_threshold_ms
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { z } from "zod";
|
||||
import { colorsSchema } from "@server/lib/colorsSchema";
|
||||
import { build } from "@server/build";
|
||||
import { getEnvOrYaml } from "@server/lib/getEnvOrYaml";
|
||||
import { DEFAULT_OFFLINE_THRESHOLDS } from "@server/lib/offlineThresholds";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
|
||||
@@ -177,36 +176,6 @@ export const privateConfigSchema = z
|
||||
.default("http://gerbil:3004")
|
||||
})
|
||||
.prefault({}),
|
||||
// How long a newt site / olm client / remote exit node has to go
|
||||
// without a ping before the offline checker marks it offline, and how
|
||||
// often the checkers run.
|
||||
offline_checker: z
|
||||
.object({
|
||||
check_interval_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(DEFAULT_OFFLINE_THRESHOLDS.checkIntervalMs),
|
||||
newt_threshold_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.default(DEFAULT_OFFLINE_THRESHOLDS.newtMs),
|
||||
// wireguard sites don't ping, so they are judged by their
|
||||
// last bandwidth update instead
|
||||
newt_wireguard_threshold_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.default(DEFAULT_OFFLINE_THRESHOLDS.newtWireguardMs),
|
||||
olm_threshold_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.default(DEFAULT_OFFLINE_THRESHOLDS.olmMs),
|
||||
remote_exit_node_threshold_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.default(DEFAULT_OFFLINE_THRESHOLDS.remoteExitNodeMs)
|
||||
})
|
||||
.prefault({}),
|
||||
flags: z
|
||||
.object({
|
||||
enable_redis: z.boolean().optional().default(false),
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
domains,
|
||||
exitNodes,
|
||||
loginPage,
|
||||
resourceMtlsCertificates,
|
||||
redirects,
|
||||
resources,
|
||||
siteNetworks,
|
||||
@@ -53,6 +52,10 @@ import {
|
||||
} from "@server/lib/traefik/browserGateway";
|
||||
import { buildWildcardTls } from "@server/lib/traefik/certResolver";
|
||||
import { buildCustomHeadersMiddleware } from "@server/lib/traefik/headersMiddleware";
|
||||
import {
|
||||
applyMtlsConfig,
|
||||
getMtlsCaCertificatesByResource
|
||||
} from "@server/lib/traefik/mtls";
|
||||
import {
|
||||
buildHttpLoadBalancerServers,
|
||||
buildLoadBalancingStrategy,
|
||||
@@ -151,8 +154,6 @@ export async function getTraefikConfig(
|
||||
maintenanceMessage: resources.maintenanceMessage,
|
||||
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
|
||||
|
||||
mtlsEnabled: resources.mtlsEnabled,
|
||||
|
||||
// Target fields
|
||||
targetId: targets.targetId,
|
||||
targetEnabled: targets.enabled,
|
||||
@@ -301,9 +302,7 @@ export async function getTraefikConfig(
|
||||
maintenanceModeType: row.maintenanceModeType,
|
||||
maintenanceTitle: row.maintenanceTitle,
|
||||
maintenanceMessage: row.maintenanceMessage,
|
||||
maintenanceEstimatedTime: row.maintenanceEstimatedTime,
|
||||
|
||||
mtlsEnabled: row.mtlsEnabled
|
||||
maintenanceEstimatedTime: row.maintenanceEstimatedTime
|
||||
});
|
||||
}
|
||||
|
||||
@@ -327,33 +326,6 @@ 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(
|
||||
@@ -516,6 +488,7 @@ export async function getTraefikConfig(
|
||||
wildcard: row.resourceWildcard,
|
||||
ssl: attachedToResource ? !!row.resourceSsl : row.ssl,
|
||||
attachedTo: attachedToResource ? "resource" : "domain",
|
||||
resourceId: row.resourceId,
|
||||
matchPath: row.matchPath,
|
||||
pathMatchType: row.pathMatchType,
|
||||
priority: row.priority,
|
||||
@@ -574,6 +547,24 @@ export async function getTraefikConfig(
|
||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
||||
}
|
||||
|
||||
// Trusted client-CA certificates for resources that require mTLS: HTTP,
|
||||
// browser-gateway (SSH/RDP/VNC) and inference resources served over SSL,
|
||||
// and the redirects attached to them.
|
||||
const mtlsCaCertsByResource = await getMtlsCaCertificatesByResource([
|
||||
...[...resourcesMap.values()]
|
||||
.filter((r) => r.mode === "http" && r.ssl)
|
||||
.map((r) => r.resourceId as number),
|
||||
...[...browserGatewayResourcesMap.values()]
|
||||
.filter((r) => r.ssl)
|
||||
.map((r) => r.resourceId),
|
||||
...inferenceResources.filter((r) => r.ssl).map((r) => r.resourceId),
|
||||
// redirects attached to a resource share its host, and are emitted
|
||||
// even on exit nodes that don't serve the resource itself
|
||||
...redirectRoutes
|
||||
.filter((r) => r.attachedTo === "resource" && r.ssl)
|
||||
.map((r) => r.resourceId!)
|
||||
]);
|
||||
|
||||
const traefikRateLimit = config.getRawConfig().traefik.rate_limit;
|
||||
|
||||
const config_output: any = {
|
||||
@@ -685,56 +676,17 @@ 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);
|
||||
|
||||
const mtlsOptionsName = resource.ssl
|
||||
? applyMtlsConfig(
|
||||
config_output,
|
||||
resource.resourceId,
|
||||
mtlsCaCertsByResource.get(resource.resourceId),
|
||||
routerMiddlewares
|
||||
)
|
||||
: undefined;
|
||||
if (mtlsOptionsName) {
|
||||
// every TLS router below (including the maintenance ones)
|
||||
// must reference the same options
|
||||
tls = { ...tls, options: mtlsOptionsName };
|
||||
}
|
||||
|
||||
@@ -975,6 +927,7 @@ export async function getTraefikConfig(
|
||||
buildRedirectConfig({
|
||||
config_output,
|
||||
redirects: redirectRoutes,
|
||||
mtlsCaCertsByResource,
|
||||
badgerMiddlewareName,
|
||||
redirectHttpsMiddlewareName,
|
||||
resolveTls: (redirect) => {
|
||||
@@ -1004,6 +957,7 @@ export async function getTraefikConfig(
|
||||
buildBrowserGatewayConfig({
|
||||
config_output,
|
||||
browserGatewayResourcesMap,
|
||||
mtlsCaCertsByResource,
|
||||
browserGatewayUiUrl,
|
||||
maintenancePageUiUrl,
|
||||
badgerMiddlewareName,
|
||||
@@ -1184,6 +1138,18 @@ export async function getTraefikConfig(
|
||||
|
||||
routerMiddlewares.push(...additionalMiddlewares);
|
||||
|
||||
const mtlsOptionsName = ir.ssl
|
||||
? applyMtlsConfig(
|
||||
config_output,
|
||||
ir.resourceId,
|
||||
mtlsCaCertsByResource.get(ir.resourceId),
|
||||
routerMiddlewares
|
||||
)
|
||||
: undefined;
|
||||
if (mtlsOptionsName) {
|
||||
tls = { ...tls, options: mtlsOptionsName };
|
||||
}
|
||||
|
||||
const { routers, services } = buildAiGatewayRouterAndService({
|
||||
routerName,
|
||||
serviceName,
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db, HostMeta, primaryDb, sites, users } from "@server/db";
|
||||
import { clients, db, HostMeta, primaryDb, sites, users } from "@server/db";
|
||||
import { hostMeta, licenseKey } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { createLocalCache } from "@server/lib/createLocalCache";
|
||||
import { validateJWT } from "./licenseJwt";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { count, eq, isNull } from "drizzle-orm";
|
||||
import moment from "moment";
|
||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
import {
|
||||
@@ -77,6 +77,7 @@ type TokenPayload = {
|
||||
tier: LicenseKeyTier;
|
||||
quantity: number;
|
||||
quantity_2: number;
|
||||
quantity_3: number;
|
||||
terminateAt: string; // ISO
|
||||
iat: number; // Issued at
|
||||
};
|
||||
@@ -190,9 +191,13 @@ LQIDAQAB
|
||||
};
|
||||
}
|
||||
|
||||
// Count used sites and users for license comparison
|
||||
// Count used sites, users, and machine clients for license comparison
|
||||
const [siteCountRes] = await db.select({ value: count() }).from(sites);
|
||||
const [userCountRes] = await db.select({ value: count() }).from(users);
|
||||
const [machineCountRes] = await db
|
||||
.select({ value: count() })
|
||||
.from(clients)
|
||||
.where(isNull(clients.userId));
|
||||
const keyIdsRes = await db
|
||||
.select({ licenseKeyId: licenseKey.licenseKeyId })
|
||||
.from(licenseKey);
|
||||
@@ -202,7 +207,8 @@ LQIDAQAB
|
||||
isHostLicensed: true,
|
||||
isLicenseValid: false,
|
||||
usedSites: siteCountRes?.value ?? 0,
|
||||
usedUsers: userCountRes?.value ?? 0
|
||||
usedUsers: userCountRes?.value ?? 0,
|
||||
usedMachines: machineCountRes?.value ?? 0
|
||||
};
|
||||
|
||||
this.checkInProgress = true;
|
||||
@@ -216,6 +222,7 @@ LQIDAQAB
|
||||
const res = this.statusCache.get("status") as LicenseStatus;
|
||||
res.usedSites = status.usedSites;
|
||||
res.usedUsers = status.usedUsers;
|
||||
res.usedMachines = status.usedMachines;
|
||||
return res;
|
||||
}
|
||||
logger.debug("Checking license status...");
|
||||
@@ -273,7 +280,8 @@ LQIDAQAB
|
||||
iat: new Date(payload.iat * 1000),
|
||||
terminateAt: new Date(payload.terminateAt),
|
||||
quantity: payload.quantity,
|
||||
quantity_2: payload.quantity_2
|
||||
quantity_2: payload.quantity_2,
|
||||
quantity_3: payload.quantity_3
|
||||
});
|
||||
|
||||
if (payload.type === "host") {
|
||||
@@ -391,6 +399,7 @@ LQIDAQAB
|
||||
cached.terminateAt = new Date(payload.terminateAt);
|
||||
cached.quantity = payload.quantity;
|
||||
cached.quantity_2 = payload.quantity_2;
|
||||
cached.quantity_3 = payload.quantity_3;
|
||||
|
||||
// Encrypt the updated token before storing
|
||||
const encryptedKey = encrypt(
|
||||
@@ -445,7 +454,8 @@ LQIDAQAB
|
||||
}
|
||||
}
|
||||
|
||||
// Compute host status: quantity = users, quantity_2 = sites
|
||||
// Compute host status: quantity = users, quantity_2 = sites,
|
||||
// quantity_3 = machine clients
|
||||
// When multiple host keys are active, prefer a valid key over an
|
||||
// invalid one, and among equally-valid keys prefer the highest tier.
|
||||
let selectedHostKey: LicenseKeyCache | undefined;
|
||||
@@ -468,7 +478,10 @@ LQIDAQAB
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites)
|
||||
// Only consider quantity if defined and >= 0 (quantity = users,
|
||||
// quantity_2 = sites, quantity_3 = machine clients). Keys issued
|
||||
// before machine limits existed omit quantity_3 and so impose no
|
||||
// machine limit.
|
||||
if (cached.quantity_2 !== undefined && cached.quantity_2 >= 0) {
|
||||
status.maxSites =
|
||||
(status.maxSites ?? 0) + cached.quantity_2;
|
||||
@@ -476,6 +489,10 @@ LQIDAQAB
|
||||
if (cached.quantity !== undefined && cached.quantity >= 0) {
|
||||
status.maxUsers = (status.maxUsers ?? 0) + cached.quantity;
|
||||
}
|
||||
if (cached.quantity_3 !== undefined && cached.quantity_3 >= 0) {
|
||||
status.maxMachines =
|
||||
(status.maxMachines ?? 0) + cached.quantity_3;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedHostKey) {
|
||||
@@ -483,12 +500,14 @@ LQIDAQAB
|
||||
status.tier = selectedHostKey.tier;
|
||||
}
|
||||
|
||||
// Invalidate license if over user or site limits
|
||||
// Invalidate license if over user, site, or machine client limits
|
||||
if (
|
||||
(status.maxSites !== undefined &&
|
||||
(status.usedSites ?? 0) > status.maxSites) ||
|
||||
(status.maxUsers !== undefined &&
|
||||
(status.usedUsers ?? 0) > status.maxUsers)
|
||||
(status.usedUsers ?? 0) > status.maxUsers) ||
|
||||
(status.maxMachines !== undefined &&
|
||||
(status.usedMachines ?? 0) > status.maxMachines)
|
||||
) {
|
||||
status.isLicenseValid = false;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
loginPageBrandingOrg,
|
||||
loginPageOrg,
|
||||
orgs,
|
||||
resourcePolicies,
|
||||
resources,
|
||||
roles,
|
||||
siteResources,
|
||||
@@ -295,18 +296,10 @@ async function disableFeature(
|
||||
await disableAISessionLogs(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.RotateCredentials:
|
||||
await disableRotateCredentials(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.MaintenancePage:
|
||||
await disablemaintenancePage(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.Mtls:
|
||||
await disableMtls(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.DevicePosture:
|
||||
await disableDevicePosture(orgId);
|
||||
break;
|
||||
@@ -532,7 +525,16 @@ async function disableMtls(orgId: string): Promise<void> {
|
||||
})
|
||||
.where(eq(resources.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled mTLS on all resources for org ${orgId}`);
|
||||
await db
|
||||
.update(resourcePolicies)
|
||||
.set({
|
||||
mtlsEnabled: false
|
||||
})
|
||||
.where(eq(resourcePolicies.orgId, orgId));
|
||||
|
||||
logger.info(
|
||||
`Disabled mTLS on all resources and resource policies for org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
async function disableDevicePosture(orgId: string): Promise<void> {}
|
||||
|
||||
@@ -222,15 +222,18 @@ export async function handleSubscriptionCreated(
|
||||
|
||||
let numUsers: number;
|
||||
let numSites: number;
|
||||
let numMachines: number;
|
||||
let tier = "enterprise";
|
||||
|
||||
if (subscriptionPriceId === priceSet[LicenseId.TIER1]) {
|
||||
numUsers = 25;
|
||||
numSites = 25;
|
||||
numMachines = 25;
|
||||
tier = "tier1";
|
||||
} else if (subscriptionPriceId === priceSet[LicenseId.TIER2]) {
|
||||
numUsers = 50;
|
||||
numSites = 100;
|
||||
numMachines = 50;
|
||||
tier = "tier2";
|
||||
} else {
|
||||
logger.error(
|
||||
@@ -240,7 +243,7 @@ export async function handleSubscriptionCreated(
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
|
||||
`License type determined: ${numUsers} users, ${numSites} sites, ${numMachines} machines for subscription ${subscription.id}`
|
||||
);
|
||||
|
||||
// Grace period of 5 days added on top of the current billing
|
||||
@@ -266,6 +269,7 @@ export async function handleSubscriptionCreated(
|
||||
paidFor: true,
|
||||
users: numUsers,
|
||||
sites: numSites,
|
||||
machines: numMachines,
|
||||
tier: tier,
|
||||
expiresAt: expiresAt
|
||||
})
|
||||
@@ -293,6 +297,7 @@ export async function handleSubscriptionCreated(
|
||||
personalUseOnly: false,
|
||||
users: numUsers,
|
||||
sites: numSites,
|
||||
machines: numMachines,
|
||||
modifySubscriptionLink: `${config.getRawConfig().app.dashboard_url}/${customer.orgId}/settings/billing`
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
exportAiSessionLogs
|
||||
} from "@server/routers/auditLogs";
|
||||
import * as misc from "#private/routers/misc";
|
||||
import * as reKey from "#private/routers/re-key";
|
||||
import * as approval from "#private/routers/approvals";
|
||||
import * as user from "#private/routers/user";
|
||||
import * as client from "#private/routers/client";
|
||||
@@ -35,8 +34,6 @@ import * as eventStreamingDestination from "#private/routers/eventStreamingDesti
|
||||
import * as alertRule from "#private/routers/alertRule";
|
||||
import * as healthChecks from "#private/routers/healthChecks";
|
||||
import * as policy from "#private/routers/policy";
|
||||
import * as resource from "#private/routers/resource";
|
||||
|
||||
import {
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction,
|
||||
@@ -617,36 +614,6 @@ authenticated.get(
|
||||
exportAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/re-key/:clientId/regenerate-client-secret",
|
||||
verifyClientAccess, // this is first to set the org id
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.rotateCredentials),
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateClientSecret
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/re-key/:siteId/regenerate-site-secret",
|
||||
verifySiteAccess, // this is first to set the org id
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.rotateCredentials),
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateSiteSecret
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/re-key/:orgId/regenerate-remote-exit-node-secret",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.rotateCredentials),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateExitNodeSecret
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/user/:userId/add-role/:roleId",
|
||||
verifyRoleAccess,
|
||||
@@ -895,25 +862,3 @@ authenticated.post(
|
||||
verifyOrgAccess,
|
||||
logs.logAccessAuditAttempt
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface LicenseKey {
|
||||
type: string;
|
||||
quantity: number;
|
||||
quantity_2: number;
|
||||
quantity_3: number;
|
||||
isValid: boolean;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -98,6 +98,7 @@ import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy";
|
||||
import { sanitizeString } from "@server/lib/sanitize";
|
||||
import {
|
||||
getRedirectsByHost,
|
||||
getWhitelistEmail,
|
||||
type RedirectByHost
|
||||
} from "@server/db/queries/verifySessionQueries";
|
||||
|
||||
@@ -1881,8 +1882,19 @@ hybridRouter.post(
|
||||
resourceId
|
||||
);
|
||||
|
||||
// Lets the node forward the whitelisted email downstream as
|
||||
// Remote-Email, same as verifySession does locally
|
||||
const whitelistEmail = result.resourceSession
|
||||
? await getWhitelistEmail(
|
||||
result.resourceSession.whitelistId,
|
||||
result.resourceSession.policyWhitelistId
|
||||
)
|
||||
: null;
|
||||
|
||||
// this is for backward compatibility with nodes that did not have the policy id checking
|
||||
const modifiedResult: ResourceSessionValidationResult = {
|
||||
const modifiedResult: ResourceSessionValidationResult & {
|
||||
whitelistEmail: string | null;
|
||||
} = {
|
||||
...result,
|
||||
resourceSession: result.resourceSession
|
||||
? {
|
||||
@@ -1901,7 +1913,8 @@ hybridRouter.post(
|
||||
result.resourceSession.whitelistId ??
|
||||
null
|
||||
}
|
||||
: null
|
||||
: null,
|
||||
whitelistEmail
|
||||
};
|
||||
|
||||
return response(res, {
|
||||
|
||||
@@ -467,36 +467,3 @@ authenticated.get(
|
||||
verifyApiKeyHasAction(ActionsEnum.getTarget),
|
||||
healthChecks.getHealthCheckStatusHistory
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
@@ -30,6 +30,11 @@ import {
|
||||
} from "@server/db";
|
||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
|
||||
import { addMtlsCertificates } from "@server/lib/mtls";
|
||||
import {
|
||||
MAX_MTLS_CERTIFICATES_PER_RESOURCE,
|
||||
parseCaCertificates
|
||||
} from "@server/lib/mtlsCertificate";
|
||||
import response from "@server/lib/response";
|
||||
import {
|
||||
getResourceRuleValueValidationError,
|
||||
@@ -112,7 +117,27 @@ const createResourcePolicyBodySchema = z.strictObject({
|
||||
.default([]),
|
||||
// rules
|
||||
applyRules: z.boolean().default(false),
|
||||
rules: z.array(ruleSchema).optional().default([])
|
||||
rules: z.array(ruleSchema).optional().default([]),
|
||||
// mTLS
|
||||
mtlsEnabled: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Require clients to present a certificate signed by one of mtlsCaCertificates. Only takes effect on resources with SSL enabled (not TCP/UDP)."
|
||||
),
|
||||
mtlsCaCertificates: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
certificate: z.string().min(1).max(100_000),
|
||||
name: z.string().min(1).max(255).optional()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.default([])
|
||||
.describe(
|
||||
"PEM-encoded trusted CA certificates for mTLS. Each certificate may be a bundle; the name is only kept for a single certificate."
|
||||
)
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
@@ -197,9 +222,52 @@ export async function createResourcePolicy(
|
||||
pincode,
|
||||
headerAuth,
|
||||
emails,
|
||||
rules
|
||||
rules,
|
||||
mtlsEnabled,
|
||||
mtlsCaCertificates
|
||||
} = parsedBody.data;
|
||||
|
||||
// Validate the CA certificates up front so bad input is a 400
|
||||
const mtlsFingerprints = new Set<string>();
|
||||
for (const { certificate } of mtlsCaCertificates) {
|
||||
try {
|
||||
for (const cert of parseCaCertificates(certificate)) {
|
||||
if (mtlsFingerprints.has(cert.fingerprint)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"The same CA certificate was provided more than once"
|
||||
)
|
||||
);
|
||||
}
|
||||
mtlsFingerprints.add(cert.fingerprint);
|
||||
}
|
||||
} catch (e) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
e instanceof Error ? e.message : "Invalid certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (mtlsFingerprints.size > MAX_MTLS_CERTIFICATES_PER_RESOURCE) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`At most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates can be added`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (mtlsEnabled && mtlsFingerprints.size === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Add at least one trusted CA certificate before enabling mTLS."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if Identity provider in `skipToIdpId` exists
|
||||
if (skipToIdpId) {
|
||||
const providerExists = await idpExistsForOrg(skipToIdpId, orgId);
|
||||
@@ -274,7 +342,8 @@ export async function createResourcePolicy(
|
||||
sso,
|
||||
idpId: skipToIdpId,
|
||||
applyRules,
|
||||
emailWhitelistEnabled
|
||||
emailWhitelistEnabled,
|
||||
mtlsEnabled
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -369,6 +438,18 @@ export async function createResourcePolicy(
|
||||
);
|
||||
}
|
||||
|
||||
for (const { certificate, name } of mtlsCaCertificates) {
|
||||
await addMtlsCertificates(
|
||||
{
|
||||
type: "policy",
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
},
|
||||
certificate,
|
||||
name,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
return newPolicy;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export * from "./reGenerateClientSecret";
|
||||
export * from "./reGenerateSiteSecret";
|
||||
export * from "./reGenerateExitNodeSecret";
|
||||
@@ -14,7 +14,7 @@
|
||||
import { db, exitNodes } from "@server/db";
|
||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { getOfflineThresholds } from "#private/lib/offlineThresholds";
|
||||
import { getOfflineThresholds } from "@server/lib/offlineThresholds";
|
||||
|
||||
// Track if the offline checker interval is running
|
||||
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
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 "#private/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import {
|
||||
MAX_MTLS_CERTIFICATES_PER_RESOURCE,
|
||||
parseCaCertificates
|
||||
} from "@server/lib/mtlsCertificate";
|
||||
import { CreateResourceMtlsCertificateResponse } from "@server/routers/resource/types";
|
||||
|
||||
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)
|
||||
});
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,3 @@
|
||||
*/
|
||||
|
||||
export * from "./getMaintenanceInfo";
|
||||
export * from "./deleteResourceMtlsCertificate";
|
||||
export * from "./createResourceMtlsCertificate";
|
||||
export * from "./listResourceMtlsCertificates";
|
||||
|
||||
@@ -857,7 +857,10 @@ export async function verifyResourceSession(
|
||||
|
||||
return allowed(
|
||||
res,
|
||||
whitelistEmail ? { email: whitelistEmail } : undefined,
|
||||
forwardedUserData(
|
||||
resource,
|
||||
whitelistEmail ? { email: whitelistEmail } : undefined
|
||||
),
|
||||
dontStripSession
|
||||
);
|
||||
}
|
||||
@@ -899,7 +902,11 @@ export async function verifyResourceSession(
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res, userData, dontStripSession);
|
||||
return allowed(
|
||||
res,
|
||||
forwardedUserData(resource, userData),
|
||||
dontStripSession
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
@@ -951,7 +958,10 @@ export async function verifyResourceSession(
|
||||
|
||||
return allowed(
|
||||
res,
|
||||
{ ...allowedUserData, dontStripSession },
|
||||
forwardedUserData(resource, {
|
||||
...allowedUserData,
|
||||
dontStripSession
|
||||
}),
|
||||
dontStripSession
|
||||
);
|
||||
}
|
||||
@@ -1158,6 +1168,19 @@ async function notAllowed(
|
||||
return response<VerifyUserResponse>(res, data);
|
||||
}
|
||||
|
||||
// Badger only sets the Remote-* identity headers for fields present in the
|
||||
// response, so omitting the user data is enough to keep them off the upstream
|
||||
// request. Some backends (e.g. embedded device web servers) reject requests
|
||||
// carrying headers they don't recognize. Inference resources skip this since
|
||||
// the AI gateway relies on those headers for attribution.
|
||||
function forwardedUserData<T>(
|
||||
resource: Resource,
|
||||
userData: T | undefined
|
||||
): T | undefined {
|
||||
// Treat a missing value (older hybrid payloads) as enabled
|
||||
return resource.forwardIdentityHeaders === false ? undefined : userData;
|
||||
}
|
||||
|
||||
function allowed(
|
||||
res: Response,
|
||||
userData?: BasicUserData | EmailOnlyUserData,
|
||||
@@ -1209,7 +1232,11 @@ async function allowAccessToken(
|
||||
|
||||
if (!tokenItem.persistSession) {
|
||||
logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody);
|
||||
return allowed(res, userData, dontStripSession);
|
||||
return allowed(
|
||||
res,
|
||||
forwardedUserData(resource, userData),
|
||||
dontStripSession
|
||||
);
|
||||
}
|
||||
|
||||
const resourceSessionToken = extractResourceSessionToken(
|
||||
@@ -1241,7 +1268,11 @@ async function allowAccessToken(
|
||||
logger.debug(
|
||||
"Resource allowed because existing access token session is valid"
|
||||
);
|
||||
return allowed(res, userData, dontStripSession);
|
||||
return allowed(
|
||||
res,
|
||||
forwardedUserData(resource, userData),
|
||||
dontStripSession
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1274,7 +1305,7 @@ async function createAccessTokenSession(
|
||||
);
|
||||
res.appendHeader("Set-Cookie", cookie);
|
||||
logger.debug("Access token is valid, creating new session");
|
||||
return allowed(res, userData);
|
||||
return allowed(res, forwardedUserData(resource, userData));
|
||||
}
|
||||
|
||||
async function getAccessTokenUserData(
|
||||
|
||||
@@ -22,6 +22,7 @@ import * as newt from "./newt";
|
||||
import * as olm from "./olm";
|
||||
import * as ssh from "./ssh";
|
||||
import * as serverInfo from "./serverInfo";
|
||||
import * as reKey from "./re-key";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
verifyAccessTokenAccess,
|
||||
@@ -1077,6 +1078,28 @@ authenticated.put(
|
||||
policy.setResourcePolicyRules
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/mtls/certificate",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateResourcePolicy),
|
||||
logActionAudit(ActionsEnum.updateResourcePolicy),
|
||||
policy.createResourcePolicyMtlsCertificate
|
||||
);
|
||||
authenticated.get(
|
||||
"/resource-policy/:resourcePolicyId/mtls/certificates",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyUserHasAction(ActionsEnum.getResourcePolicy),
|
||||
policy.listResourcePolicyMtlsCertificates
|
||||
);
|
||||
authenticated.delete(
|
||||
"/resource-policy/:resourcePolicyId/mtls/certificate/:mtlsCertificateId",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateResourcePolicy),
|
||||
logActionAudit(ActionsEnum.updateResourcePolicy),
|
||||
policy.deleteResourcePolicyMtlsCertificate
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/password`,
|
||||
verifyResourceAccess,
|
||||
@@ -1972,6 +1995,52 @@ authenticated.post(
|
||||
certificates.restartCertificate
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/re-key/:clientId/regenerate-client-secret",
|
||||
verifyClientAccess, // this is first to set the org id
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateClientSecret
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/re-key/:siteId/regenerate-site-secret",
|
||||
verifySiteAccess, // this is first to set the org id
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateSiteSecret
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/re-key/:orgId/regenerate-remote-exit-node-secret",
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateExitNodeSecret
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
// Auth routes
|
||||
export const authRouter = Router();
|
||||
unauthenticated.use("/auth", authRouter);
|
||||
|
||||
@@ -8,6 +8,7 @@ export type GeneratedLicenseKey = {
|
||||
type: string;
|
||||
users: number;
|
||||
sites: number;
|
||||
machines: number;
|
||||
};
|
||||
|
||||
export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[];
|
||||
@@ -22,6 +23,7 @@ export type NewLicenseKey = {
|
||||
type: string;
|
||||
quantity: number;
|
||||
quantity_2: number;
|
||||
quantity_3: number;
|
||||
isValid: boolean;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -18,6 +18,7 @@ import * as aiBudget from "./aiBudget";
|
||||
import * as virtualApiKey from "./virtualApiKey";
|
||||
import * as redirect from "./redirect";
|
||||
import * as labels from "./labels";
|
||||
import * as reKey from "./re-key";
|
||||
import {
|
||||
verifyApiKey,
|
||||
verifyApiKeyOrgAccess,
|
||||
@@ -149,6 +150,24 @@ authenticated.post(
|
||||
site.updateSite
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/re-key/:siteId/regenerate-site-secret",
|
||||
verifyApiKeySiteAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.reGenerateSecret),
|
||||
logActionAudit(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateSiteSecret
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/re-key/:orgId/regenerate-remote-exit-node-secret",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.reGenerateSecret),
|
||||
logActionAudit(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateExitNodeSecret
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/reset-bandwidth",
|
||||
verifyApiKeyOrgAccess,
|
||||
@@ -1012,6 +1031,39 @@ authenticated.put(
|
||||
policy.setResourcePolicyRules
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
[
|
||||
"/resource-policy/:resourcePolicyId/mtls/certificate",
|
||||
"/public-resource-policy/:resourcePolicyId/mtls/certificate"
|
||||
],
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy),
|
||||
logActionAudit(ActionsEnum.updateResourcePolicy),
|
||||
policy.createResourcePolicyMtlsCertificate
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
[
|
||||
"/resource-policy/:resourcePolicyId/mtls/certificates",
|
||||
"/public-resource-policy/:resourcePolicyId/mtls/certificates"
|
||||
],
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
|
||||
policy.listResourcePolicyMtlsCertificates
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
[
|
||||
"/resource-policy/:resourcePolicyId/mtls/certificate/:mtlsCertificateId",
|
||||
"/public-resource-policy/:resourcePolicyId/mtls/certificate/:mtlsCertificateId"
|
||||
],
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy),
|
||||
logActionAudit(ActionsEnum.updateResourcePolicy),
|
||||
policy.deleteResourcePolicyMtlsCertificate
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
[
|
||||
"/resource/:resourceId/roles/add",
|
||||
@@ -1497,6 +1549,15 @@ authenticated.post(
|
||||
client.updateClient
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/re-key/:clientId/regenerate-client-secret",
|
||||
verifyApiKeyClientAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.reGenerateSecret),
|
||||
logActionAudit(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateClientSecret
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/blueprint",
|
||||
verifyApiKeyOrgAccess,
|
||||
@@ -1935,3 +1996,36 @@ authenticated.put(
|
||||
logActionAudit(ActionsEnum.detachLabelFromItem),
|
||||
labels.detachLabelFromItem
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { hasActiveConnections } from "#dynamic/routers/ws";
|
||||
import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "@server/lib/alerts";
|
||||
import { getOfflineThresholds } from "#dynamic/lib/offlineThresholds";
|
||||
import { getOfflineThresholds } from "@server/lib/offlineThresholds";
|
||||
|
||||
// Track if the offline checker interval is running
|
||||
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { eq, lt, isNull, and, or } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { sendTerminateClient } from "../client/terminate";
|
||||
import { OlmErrorCodes } from "./error";
|
||||
import { getOfflineThresholds } from "#dynamic/lib/offlineThresholds";
|
||||
import { getOfflineThresholds } from "@server/lib/offlineThresholds";
|
||||
|
||||
// Track if the offline checker interval is running
|
||||
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resourcePolicies } 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 {
|
||||
addMtlsCertificates,
|
||||
type MtlsCertificateInfo
|
||||
} from "@server/lib/mtls";
|
||||
|
||||
const createResourcePolicyMtlsCertificateParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const createResourcePolicyMtlsCertificateBodySchema = z.strictObject({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
certificate: z.string().min(1).max(100_000)
|
||||
});
|
||||
|
||||
export type CreateResourcePolicyMtlsCertificateResponse = {
|
||||
certificates: MtlsCertificateInfo[];
|
||||
};
|
||||
|
||||
const description =
|
||||
"Add a trusted CA certificate that clients must present a certificate signed by when mTLS is enabled on a resource policy. Accepts a PEM bundle; each certificate in the bundle is stored separately.";
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/mtls/certificate",
|
||||
description,
|
||||
tags: [OpenAPITags.PublicResourcePolicyLegacy],
|
||||
request: {
|
||||
params: createResourcePolicyMtlsCertificateParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createResourcePolicyMtlsCertificateBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/public-resource-policy/{resourcePolicyId}/mtls/certificate",
|
||||
description,
|
||||
tags: [OpenAPITags.PublicResourcePolicy],
|
||||
request: {
|
||||
params: createResourcePolicyMtlsCertificateParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createResourcePolicyMtlsCertificateBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function createResourcePolicyMtlsCertificate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams =
|
||||
createResourcePolicyMtlsCertificateParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody =
|
||||
createResourcePolicyMtlsCertificateBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { name, certificate } = parsedBody.data;
|
||||
|
||||
const [policy] = await db
|
||||
.select({ resourcePolicyId: resourcePolicies.resourcePolicyId })
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
|
||||
);
|
||||
}
|
||||
|
||||
const certificates = await addMtlsCertificates(
|
||||
{ type: "policy", resourcePolicyId },
|
||||
certificate,
|
||||
name
|
||||
);
|
||||
|
||||
return response<CreateResourcePolicyMtlsCertificateResponse>(res, {
|
||||
data: { certificates },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "CA certificate added successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
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 { removeMtlsCertificate } from "@server/lib/mtls";
|
||||
|
||||
const deleteResourcePolicyMtlsCertificateParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()),
|
||||
mtlsCertificateId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
export type DeleteResourcePolicyMtlsCertificateResponse = {
|
||||
// true when removing the last CA certificate forced mTLS off
|
||||
mtlsDisabled: boolean;
|
||||
};
|
||||
|
||||
const description =
|
||||
"Remove a trusted CA certificate from a resource policy. If it is the last one, mTLS is disabled on the policy since it can no longer verify any client.";
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/resource-policy/{resourcePolicyId}/mtls/certificate/{mtlsCertificateId}",
|
||||
description,
|
||||
tags: [OpenAPITags.PublicResourcePolicyLegacy],
|
||||
request: {
|
||||
params: deleteResourcePolicyMtlsCertificateParamsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/public-resource-policy/{resourcePolicyId}/mtls/certificate/{mtlsCertificateId}",
|
||||
description,
|
||||
tags: [OpenAPITags.PublicResourcePolicy],
|
||||
request: {
|
||||
params: deleteResourcePolicyMtlsCertificateParamsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function deleteResourcePolicyMtlsCertificate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams =
|
||||
deleteResourcePolicyMtlsCertificateParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { resourcePolicyId, mtlsCertificateId } = parsedParams.data;
|
||||
|
||||
const result = await removeMtlsCertificate(
|
||||
{ type: "policy", resourcePolicyId },
|
||||
mtlsCertificateId
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`CA certificate with ID ${mtlsCertificateId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<DeleteResourcePolicyMtlsCertificateResponse>(res, {
|
||||
data: result,
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ export async function queryResourcePolicy(
|
||||
sso: resourcePolicies.sso,
|
||||
applyRules: resourcePolicies.applyRules,
|
||||
emailWhitelistEnabled: resourcePolicies.emailWhitelistEnabled,
|
||||
mtlsEnabled: resourcePolicies.mtlsEnabled,
|
||||
idpId: resourcePolicies.idpId,
|
||||
niceId: resourcePolicies.niceId,
|
||||
name: resourcePolicies.name,
|
||||
|
||||
@@ -6,3 +6,6 @@ export * from "./setResourcePolicyPincode";
|
||||
export * from "./setResourcePolicyHeaderAuth";
|
||||
export * from "./setResourcePolicyWhitelist";
|
||||
export * from "./setResourcePolicyRules";
|
||||
export * from "./createResourcePolicyMtlsCertificate";
|
||||
export * from "./listResourcePolicyMtlsCertificates";
|
||||
export * from "./deleteResourcePolicyMtlsCertificate";
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resourcePolicies } 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 {
|
||||
listMtlsCertificates,
|
||||
type MtlsCertificateInfo
|
||||
} from "@server/lib/mtls";
|
||||
|
||||
const listResourcePolicyMtlsCertificatesParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
export type ListResourcePolicyMtlsCertificatesResponse = {
|
||||
certificates: MtlsCertificateInfo[];
|
||||
};
|
||||
|
||||
const description =
|
||||
"List the trusted CA certificates for a resource policy's mTLS.";
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/resource-policy/{resourcePolicyId}/mtls/certificates",
|
||||
description,
|
||||
tags: [OpenAPITags.PublicResourcePolicyLegacy],
|
||||
request: {
|
||||
params: listResourcePolicyMtlsCertificatesParamsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/public-resource-policy/{resourcePolicyId}/mtls/certificates",
|
||||
description,
|
||||
tags: [OpenAPITags.PublicResourcePolicy],
|
||||
request: {
|
||||
params: listResourcePolicyMtlsCertificatesParamsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listResourcePolicyMtlsCertificates(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams =
|
||||
listResourcePolicyMtlsCertificatesParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
|
||||
const [policy] = await db
|
||||
.select({ resourcePolicyId: resourcePolicies.resourcePolicyId })
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
|
||||
);
|
||||
}
|
||||
|
||||
const certificates = await listMtlsCertificates({
|
||||
type: "policy",
|
||||
resourcePolicyId
|
||||
});
|
||||
|
||||
return response<ListResourcePolicyMtlsCertificatesResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { db, orgs, resourcePolicies, type ResourcePolicy } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import response from "@server/lib/response";
|
||||
import { countMtlsCertificates } from "@server/lib/mtls";
|
||||
|
||||
const updateResourcePolicyParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
@@ -15,7 +16,13 @@ const updateResourcePolicyParamsSchema = z.strictObject({
|
||||
|
||||
const updateResourcePolicyBodySchema = z.strictObject({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
niceId: z.string().min(1).max(255).optional()
|
||||
niceId: z.string().min(1).max(255).optional(),
|
||||
mtlsEnabled: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Require clients to present a certificate signed by one of the policy's trusted CA certificates. Requires at least one CA certificate, and only takes effect on resources with SSL enabled (not TCP/UDP)."
|
||||
)
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
@@ -171,6 +178,21 @@ export async function updateResourcePolicy(
|
||||
}
|
||||
}
|
||||
|
||||
if (updateData.mtlsEnabled === true) {
|
||||
const certificateCount = await countMtlsCertificates({
|
||||
type: "policy",
|
||||
resourcePolicyId: policy.resourcePolicyId
|
||||
});
|
||||
if (certificateCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Add at least one trusted CA certificate before enabling mTLS."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedPolicy = await db.transaction(async (trx) => {
|
||||
const [updated] = await trx
|
||||
.update(resourcePolicies)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./reGenerateClientSecret";
|
||||
export * from "./reGenerateSiteSecret";
|
||||
export * from "./reGenerateExitNodeSecret";
|
||||
+35
-14
@@ -1,16 +1,3 @@
|
||||
/*
|
||||
* 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 { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, Olm, olms } from "@server/db";
|
||||
@@ -23,7 +10,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { disconnectClient, sendToClient } from "#private/routers/ws";
|
||||
import { disconnectClient, sendToClient } from "#dynamic/routers/ws";
|
||||
import { OlmErrorCodes, sendOlmError } from "@server/routers/olm/error";
|
||||
import { sendTerminateClient } from "@server/routers/client/terminate";
|
||||
|
||||
@@ -39,6 +26,40 @@ const reGenerateSecretBodySchema = z.strictObject({
|
||||
|
||||
export type ReGenerateSecretBody = z.infer<typeof reGenerateSecretBodySchema>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/re-key/{clientId}/regenerate-client-secret",
|
||||
description:
|
||||
"Regenerate the secret for a machine client's olm. Optionally disconnects the olm so it must reconnect with the new secret.",
|
||||
tags: [OpenAPITags.Client],
|
||||
request: {
|
||||
params: reGenerateSecretParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: reGenerateSecretBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.object({ olmId: z.string() }),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function reGenerateClientSecret(
|
||||
req: Request,
|
||||
res: Response,
|
||||
+50
-16
@@ -1,16 +1,3 @@
|
||||
/*
|
||||
* 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 { NextFunction, Request, Response } from "express";
|
||||
import {
|
||||
db,
|
||||
@@ -30,7 +17,7 @@ import { hashPassword } from "@server/auth/password";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { disconnectClient, sendToClient } from "#private/routers/ws";
|
||||
import { disconnectClient, sendToClient } from "#dynamic/routers/ws";
|
||||
|
||||
export const paramsSchema = z.object({
|
||||
orgId: z.string()
|
||||
@@ -42,6 +29,40 @@ const bodySchema = z.strictObject({
|
||||
disconnect: z.boolean().optional().default(true)
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/re-key/{orgId}/regenerate-remote-exit-node-secret",
|
||||
description:
|
||||
"Regenerate the secret for a remote exit node. Optionally disconnects the node so it must reconnect with the new secret.",
|
||||
tags: [OpenAPITags.RemoteExitNode],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.null(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function reGenerateExitNodeSecret(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -68,12 +89,25 @@ export async function reGenerateExitNodeSecret(
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { remoteExitNodeId, secret, disconnect } = parsedBody.data;
|
||||
|
||||
// Only allow re-keying a node that belongs to this org (a node can be
|
||||
// shared across several orgs; any of them may re-key it)
|
||||
const [existingRemoteExitNode] = await db
|
||||
.select()
|
||||
.select({ remoteExitNodeId: remoteExitNodes.remoteExitNodeId })
|
||||
.from(remoteExitNodes)
|
||||
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId));
|
||||
.innerJoin(
|
||||
exitNodeOrgs,
|
||||
eq(exitNodeOrgs.exitNodeId, remoteExitNodes.exitNodeId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId),
|
||||
eq(exitNodeOrgs.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingRemoteExitNode) {
|
||||
return next(
|
||||
+35
-14
@@ -1,16 +1,3 @@
|
||||
/*
|
||||
* 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 { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, Newt, newts, sites } from "@server/db";
|
||||
@@ -24,7 +11,7 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { addPeer, deletePeer } from "@server/routers/gerbil/peers";
|
||||
import { getAllowedIps } from "@server/routers/target/helpers";
|
||||
import { disconnectClient, sendToClient } from "#private/routers/ws";
|
||||
import { disconnectClient, sendToClient } from "#dynamic/routers/ws";
|
||||
|
||||
const updateSiteParamsSchema = z.strictObject({
|
||||
siteId: z.coerce.number().int().positive()
|
||||
@@ -37,6 +24,40 @@ const updateSiteBodySchema = z.strictObject({
|
||||
disconnect: z.boolean().optional().default(true)
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/re-key/{siteId}/regenerate-site-secret",
|
||||
description:
|
||||
"Regenerate the credentials for a site. For newt sites a new secret is required; for wireguard sites a new public key is required.",
|
||||
tags: [OpenAPITags.Site],
|
||||
request: {
|
||||
params: updateSiteParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: updateSiteBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.object({ newtId: z.string().optional() }),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function reGenerateSiteSecret(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, 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 { addMtlsCertificates, getResourceMtlsOwner } from "@server/lib/mtls";
|
||||
import { resourceModeSupportsMtls } from "@server/lib/mtlsModes";
|
||||
import { CreateResourceMtlsCertificateResponse } from "@server/routers/resource/types";
|
||||
|
||||
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)
|
||||
});
|
||||
|
||||
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. When no shared resource policy is assigned, this updates the resource's inline policy.";
|
||||
|
||||
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 (!resourceModeSupportsMtls(resource.mode)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"mTLS is not supported on TCP/UDP resources"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const inserted = await addMtlsCertificates(
|
||||
getResourceMtlsOwner(resource),
|
||||
certificate,
|
||||
name
|
||||
);
|
||||
|
||||
return response<CreateResourceMtlsCertificateResponse>(res, {
|
||||
data: {
|
||||
certificates: inserted.map((c) => ({ ...c, resourceId }))
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "CA certificate added successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
+28
-49
@@ -1,13 +1,14 @@
|
||||
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 { db, 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 { getResourceMtlsOwner, removeMtlsCertificate } from "@server/lib/mtls";
|
||||
import { DeleteResourceMtlsCertificateResponse } from "@server/routers/resource/types";
|
||||
|
||||
const deleteResourceMtlsCertificateParamsSchema = z.strictObject({
|
||||
@@ -16,7 +17,7 @@ const deleteResourceMtlsCertificateParamsSchema = z.strictObject({
|
||||
});
|
||||
|
||||
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.";
|
||||
"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. When no shared resource policy is assigned, this updates the resource's inline policy.";
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
@@ -58,21 +59,31 @@ export async function deleteResourceMtlsCertificate(
|
||||
}
|
||||
const { resourceId, mtlsCertificateId } = parsedParams.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(resourceMtlsCertificates)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
resourceMtlsCertificates.mtlsCertificateId,
|
||||
mtlsCertificateId
|
||||
),
|
||||
eq(resourceMtlsCertificates.resourceId, resourceId)
|
||||
)
|
||||
)
|
||||
const [resource] = await db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
resourcePolicyId: resources.resourcePolicyId,
|
||||
defaultResourcePolicyId: resources.defaultResourcePolicyId
|
||||
})
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const result = await removeMtlsCertificate(
|
||||
getResourceMtlsOwner(resource),
|
||||
mtlsCertificateId
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
@@ -81,39 +92,7 @@ export async function deleteResourceMtlsCertificate(
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
const { mtlsDisabled } = result;
|
||||
|
||||
return response<DeleteResourceMtlsCertificateResponse>(res, {
|
||||
data: { mtlsDisabled },
|
||||
@@ -19,6 +19,7 @@ import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { build } from "@server/build";
|
||||
import { isResourceMtlsEnforced } from "@server/lib/mtls";
|
||||
|
||||
const getResourceAuthInfoSchema = z.strictObject({
|
||||
resourceGuid: z.string()
|
||||
@@ -39,6 +40,8 @@ export type GetResourceAuthInfoResponse = {
|
||||
wildcard: boolean;
|
||||
fullDomain: string | null;
|
||||
whitelist: boolean;
|
||||
// a client certificate is required during the TLS handshake
|
||||
mtls: boolean;
|
||||
skipToIdpId: number | null;
|
||||
orgId: string;
|
||||
postAuthPath: string | null;
|
||||
@@ -226,6 +229,7 @@ export async function getResourceAuthInfo(
|
||||
wildcard: resource.wildcard ?? false,
|
||||
fullDomain: resource.fullDomain,
|
||||
whitelist: effectivePolicy?.emailWhitelistEnabled ?? false,
|
||||
mtls: await isResourceMtlsEnforced(resource.resourceId),
|
||||
skipToIdpId: effectivePolicy?.idpId ?? resource.skipToIdpId,
|
||||
orgId: resource.orgId,
|
||||
postAuthPath: resource.postAuthPath ?? null,
|
||||
|
||||
@@ -45,3 +45,6 @@ export * from "./addAiProviderToResource";
|
||||
export * from "./removeAiProviderFromResource";
|
||||
export * from "./exportResourceBlueprint";
|
||||
export * from "./types";
|
||||
export * from "./deleteResourceMtlsCertificate";
|
||||
export * from "./createResourceMtlsCertificate";
|
||||
export * from "./listResourceMtlsCertificates";
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Resource, ResourcePolicy } from "@server/db";
|
||||
type InlinePolicyFields = Pick<
|
||||
ResourcePolicy,
|
||||
"sso" | "emailWhitelistEnabled" | "applyRules" | "idpId"
|
||||
>;
|
||||
> &
|
||||
Partial<Pick<ResourcePolicy, "mtlsEnabled">>;
|
||||
|
||||
export function applyInlinePolicyFields<T extends Resource>(
|
||||
resource: T,
|
||||
@@ -14,6 +15,7 @@ export function applyInlinePolicyFields<T extends Resource>(
|
||||
sso: policy?.sso ?? null,
|
||||
emailWhitelistEnabled: policy?.emailWhitelistEnabled ?? null,
|
||||
applyRules: policy?.applyRules ?? null,
|
||||
skipToIdpId: policy?.idpId ?? null
|
||||
skipToIdpId: policy?.idpId ?? null,
|
||||
mtlsEnabled: policy?.mtlsEnabled ?? resource.mtlsEnabled
|
||||
};
|
||||
}
|
||||
|
||||
+13
-24
@@ -1,39 +1,22 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resourceMtlsCertificates, resources } from "@server/db";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db, 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 { getResourceMtlsOwner, listMtlsCertificates } from "@server/lib/mtls";
|
||||
import { ListResourceMtlsCertificatesResponse } from "@server/routers/resource/types";
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
const description = "List the trusted CA certificates for a resource's mTLS.";
|
||||
const description =
|
||||
"List the trusted CA certificates for a resource's mTLS. When no shared resource policy is assigned, these are the resource's inline policy certificates.";
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
@@ -77,7 +60,11 @@ export async function listResourceMtlsCertificates(
|
||||
const { resourceId } = parsedParams.data;
|
||||
|
||||
const [resource] = await db
|
||||
.select({ resourceId: resources.resourceId })
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
resourcePolicyId: resources.resourcePolicyId,
|
||||
defaultResourcePolicyId: resources.defaultResourcePolicyId
|
||||
})
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
@@ -91,7 +78,9 @@ export async function listResourceMtlsCertificates(
|
||||
);
|
||||
}
|
||||
|
||||
const certificates = await queryResourceMtlsCertificates(resourceId);
|
||||
const certificates = (
|
||||
await listMtlsCertificates(getResourceMtlsOwner(resource))
|
||||
).map((c) => ({ ...c, resourceId }));
|
||||
|
||||
return response<ListResourceMtlsCertificatesResponse>(res, {
|
||||
data: { certificates },
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type Label
|
||||
} from "@server/db";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { effectiveMtlsSql } from "@server/lib/mtls";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
@@ -111,7 +112,7 @@ const listResourcesSchema = z.strictObject({
|
||||
type: "string",
|
||||
enum: ["protected", "not_protected", "none"],
|
||||
description:
|
||||
"Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)."
|
||||
"Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, email whitelist, or mTLS). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)."
|
||||
}),
|
||||
healthStatus: z
|
||||
.enum(["healthy", "degraded", "unhealthy", "unknown"])
|
||||
@@ -188,6 +189,7 @@ export type ResourceWithTargets = {
|
||||
sso: boolean;
|
||||
pincodeId: number | null;
|
||||
whitelist: boolean;
|
||||
mtls: boolean;
|
||||
proxyPort: number | null;
|
||||
enabled: boolean;
|
||||
domainId: string | null;
|
||||
@@ -306,6 +308,7 @@ function queryResourcesBase() {
|
||||
sso: effectiveSso,
|
||||
pincodeId: effectivePincodeId,
|
||||
whitelist: effectiveWhitelist,
|
||||
mtls: effectiveMtlsSql(sharedPolicy, defaultPolicy),
|
||||
proxyPort: resources.proxyPort,
|
||||
enabled: resources.enabled,
|
||||
domainId: resources.domainId,
|
||||
@@ -646,6 +649,7 @@ export async function listResources(
|
||||
${resourcePassword.passwordId}
|
||||
)
|
||||
`;
|
||||
const effectiveMtls = effectiveMtlsSql(sharedPolicy, defaultPolicy);
|
||||
const browserGatewayModes = ["http", "ssh", "rdp", "vnc"] as const;
|
||||
|
||||
switch (authState) {
|
||||
@@ -662,6 +666,7 @@ export async function listResources(
|
||||
or(
|
||||
effectiveSso,
|
||||
effectiveWhitelist,
|
||||
effectiveMtls,
|
||||
not(isNull(effectiveHeaderAuthId)),
|
||||
not(isNull(effectivePincodeId)),
|
||||
not(isNull(effectivePasswordId))
|
||||
@@ -675,6 +680,7 @@ export async function listResources(
|
||||
inArray(resources.mode, browserGatewayModes),
|
||||
not(effectiveSso),
|
||||
not(effectiveWhitelist),
|
||||
not(effectiveMtls),
|
||||
isNull(effectiveHeaderAuthId),
|
||||
isNull(effectivePincodeId),
|
||||
isNull(effectivePasswordId)
|
||||
@@ -850,6 +856,7 @@ export async function listResources(
|
||||
sso: row.sso ?? false,
|
||||
pincodeId: row.pincodeId,
|
||||
whitelist: row.whitelist ?? false,
|
||||
mtls: Boolean(row.mtls),
|
||||
proxyPort: row.proxyPort,
|
||||
wildcard: row.wildcard,
|
||||
mode: row.mode,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
loginPage,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourceMtlsCertificates,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourceRules,
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
resourcePolicies,
|
||||
resources
|
||||
} from "@server/db";
|
||||
import { eq, and, ne, count } from "drizzle-orm";
|
||||
import { eq, and, ne } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -49,6 +48,13 @@ import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { applyInlinePolicyFields } from "./inlinePolicyFields";
|
||||
import {
|
||||
clearMtls,
|
||||
countMtlsCertificates,
|
||||
getResourceMtlsOwner,
|
||||
isMtlsEnabled
|
||||
} from "@server/lib/mtls";
|
||||
import { resourceModeSupportsMtls } from "@server/lib/mtlsModes";
|
||||
|
||||
const updateResourceParamsSchema = z.strictObject({
|
||||
resourceId: z.coerce.number().int().positive()
|
||||
@@ -98,6 +104,12 @@ const updateHttpResourceBodySchema = z
|
||||
),
|
||||
tlsServerName: z.string().nullable().optional(),
|
||||
setHostHeader: z.string().nullable().optional(),
|
||||
forwardIdentityHeaders: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Whether to forward the authenticated user's identity to the backend in the Remote-User, Remote-Email, Remote-Name, Remote-Role, and Remote-User-Id headers."
|
||||
),
|
||||
skipToIdpId: z
|
||||
.int()
|
||||
.positive()
|
||||
@@ -128,7 +140,7 @@ const updateHttpResourceBodySchema = 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."
|
||||
"Require clients to present a certificate signed by one of the resource's trusted CA certificates. Requires SSL and at least one CA certificate. When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this applies to this resource only if the shared policy does not enable mTLS itself."
|
||||
),
|
||||
postAuthPath: z.string().nullable().optional(),
|
||||
// SSH settings
|
||||
@@ -418,7 +430,7 @@ async function clearResourceSpecificSettings(
|
||||
if (adminRole.length === 0) {
|
||||
throw new Error(`Admin role not found for org ${orgId}`);
|
||||
}
|
||||
// remove the resource specific pincode, password, header auth, rules, nad whitelist entries so that the resource will fall back to the policy settings
|
||||
// remove the resource specific pincode, password, header auth, rules, whitelist entries and mTLS so that the resource will fall back to the policy settings
|
||||
await Promise.all([
|
||||
trx
|
||||
.delete(resourcePassword)
|
||||
@@ -443,6 +455,7 @@ async function clearResourceSpecificSettings(
|
||||
trx
|
||||
.delete(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId)),
|
||||
clearMtls({ type: "resource", resourceId }, trx),
|
||||
// delete the roles and the users as well
|
||||
trx
|
||||
.delete(userResources)
|
||||
@@ -521,38 +534,22 @@ async function updateHttpResource(
|
||||
}
|
||||
}
|
||||
|
||||
const mtlsOwner = getResourceMtlsOwner(resource);
|
||||
const effectiveSsl = updateData.ssl ?? resource.ssl;
|
||||
const effectiveMtlsEnabled = updateData.mtlsEnabled ?? resource.mtlsEnabled;
|
||||
const effectiveMtlsEnabled =
|
||||
updateData.mtlsEnabled ?? (await isMtlsEnabled(mtlsOwner));
|
||||
|
||||
if (updateData.mtlsEnabled === true) {
|
||||
if (resource.mode !== "http") {
|
||||
if (!resourceModeSupportsMtls(resource.mode)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"mTLS is only supported on HTTP resources."
|
||||
"mTLS is not supported on TCP/UDP 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) {
|
||||
if ((await countMtlsCertificates(mtlsOwner)) === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
@@ -572,8 +569,10 @@ async function updateHttpResource(
|
||||
}
|
||||
|
||||
// catch when the resource policy changes or gets cleared
|
||||
if (updateData.resourcePolicyId !== undefined &&
|
||||
resource.resourcePolicyId !== updateData.resourcePolicyId) {
|
||||
if (
|
||||
updateData.resourcePolicyId !== undefined &&
|
||||
resource.resourcePolicyId !== updateData.resourcePolicyId
|
||||
) {
|
||||
await clearResourceSpecificSettings(
|
||||
resource.resourceId,
|
||||
resource.orgId,
|
||||
@@ -813,6 +812,7 @@ async function updateHttpResource(
|
||||
emailWhitelistEnabled,
|
||||
applyRules,
|
||||
skipToIdpId,
|
||||
mtlsEnabled,
|
||||
...resourceOnlyDataRest
|
||||
} = updateData;
|
||||
|
||||
@@ -830,6 +830,7 @@ async function updateHttpResource(
|
||||
policyUpdate.emailWhitelistEnabled = emailWhitelistEnabled;
|
||||
if (applyRules !== undefined) policyUpdate.applyRules = applyRules;
|
||||
if (skipToIdpId !== undefined) policyUpdate.idpId = skipToIdpId;
|
||||
if (mtlsEnabled !== undefined) policyUpdate.mtlsEnabled = mtlsEnabled;
|
||||
|
||||
if (Object.keys(policyUpdate).length > 0) {
|
||||
await db
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { db } from "@server/db/pg/driver";
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
const version = "1.25.0";
|
||||
|
||||
@@ -24,6 +30,21 @@ export default async function migration() {
|
||||
"createdAt" bigint NOT NULL
|
||||
);
|
||||
`);
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "resourcePolicyMtlsCertificates" (
|
||||
"mtlsCertificateId" serial PRIMARY KEY NOT NULL,
|
||||
"resourcePolicyId" integer NOT NULL,
|
||||
"name" varchar,
|
||||
"certificate" text NOT NULL,
|
||||
"subject" varchar,
|
||||
"issuer" varchar,
|
||||
"serialNumber" varchar,
|
||||
"fingerprint" varchar,
|
||||
"notBefore" bigint,
|
||||
"notAfter" bigint,
|
||||
"createdAt" bigint NOT NULL
|
||||
);
|
||||
`);
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "clientOrgRoles" (
|
||||
"clientId" integer NOT NULL,
|
||||
@@ -61,6 +82,15 @@ export default async function migration() {
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ADD COLUMN "mtlsEnabled" boolean DEFAULT false NOT NULL;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ADD COLUMN "forwardIdentityHeaders" boolean DEFAULT true NOT NULL;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resourcePolicies" ADD COLUMN "mtlsEnabled" boolean DEFAULT false NOT NULL;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resourcePolicyMtlsCertificates" ADD CONSTRAINT "resourcePolicyMtlsCertificates_resourcePolicyId_resourcePolicies_resourcePolicyId_fk" FOREIGN KEY ("resourcePolicyId") REFERENCES "public"."resourcePolicies"("resourcePolicyId") ON DELETE cascade ON UPDATE no action;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resourceMtlsCertificates" ADD CONSTRAINT "resourceMtlsCertificates_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action;`
|
||||
);
|
||||
@@ -95,5 +125,48 @@ export default async function migration() {
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
const traefikPath = path.join(
|
||||
APP_PATH,
|
||||
"traefik",
|
||||
"traefik_config.yml"
|
||||
);
|
||||
|
||||
const schema = z.object({
|
||||
experimental: z.object({
|
||||
plugins: z.object({
|
||||
badger: z.object({
|
||||
moduleName: z.string(),
|
||||
version: z.string()
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
|
||||
const traefikConfig = yaml.load(traefikFileContents) as any;
|
||||
|
||||
const parsedConfig = schema.safeParse(traefikConfig);
|
||||
|
||||
if (!parsedConfig.success) {
|
||||
throw new Error(fromZodError(parsedConfig.error).toString());
|
||||
}
|
||||
|
||||
traefikConfig.experimental.plugins.badger.version = "v1.8.0";
|
||||
|
||||
const updatedTraefikYaml = yaml.dump(traefikConfig);
|
||||
|
||||
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
|
||||
|
||||
console.log(
|
||||
"Updated the version of Badger in your Traefik configuration to v1.8.0"
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually. Check the release notes for this version for more information."
|
||||
);
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
const version = "1.25.0";
|
||||
|
||||
@@ -32,6 +36,24 @@ export default async function migration() {
|
||||
);
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE 'resourcePolicyMtlsCertificates' (
|
||||
'mtlsCertificateId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
'resourcePolicyId' integer NOT NULL,
|
||||
'name' text,
|
||||
'certificate' text NOT NULL,
|
||||
'subject' text,
|
||||
'issuer' text,
|
||||
'serialNumber' text,
|
||||
'fingerprint' text,
|
||||
'notBefore' integer,
|
||||
'notAfter' integer,
|
||||
'createdAt' integer NOT NULL,
|
||||
FOREIGN KEY ('resourcePolicyId') REFERENCES 'resourcePolicies'('resourcePolicyId') ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE 'clientOrgRoles' (
|
||||
@@ -94,6 +116,16 @@ export default async function migration() {
|
||||
ALTER TABLE 'resources' ADD 'mtlsEnabled' integer DEFAULT false NOT NULL;
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
ALTER TABLE 'resources' ADD 'forwardIdentityHeaders' integer DEFAULT true NOT NULL;
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
ALTER TABLE 'resourcePolicies' ADD 'mtlsEnabled' integer DEFAULT false NOT NULL;
|
||||
`
|
||||
).run();
|
||||
})();
|
||||
|
||||
db.pragma("foreign_keys = ON");
|
||||
@@ -104,5 +136,48 @@ export default async function migration() {
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
const traefikPath = path.join(
|
||||
APP_PATH,
|
||||
"traefik",
|
||||
"traefik_config.yml"
|
||||
);
|
||||
|
||||
const schema = z.object({
|
||||
experimental: z.object({
|
||||
plugins: z.object({
|
||||
badger: z.object({
|
||||
moduleName: z.string(),
|
||||
version: z.string()
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
|
||||
const traefikConfig = yaml.load(traefikFileContents) as any;
|
||||
|
||||
const parsedConfig = schema.safeParse(traefikConfig);
|
||||
|
||||
if (!parsedConfig.success) {
|
||||
throw new Error(fromZodError(parsedConfig.error).toString());
|
||||
}
|
||||
|
||||
traefikConfig.experimental.plugins.badger.version = "v1.8.0";
|
||||
|
||||
const updatedTraefikYaml = yaml.dump(traefikConfig);
|
||||
|
||||
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
|
||||
|
||||
console.log(
|
||||
"Updated the version of Badger in your Traefik configuration to v1.8.0"
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually. Check the release notes for this version for more information."
|
||||
);
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
|
||||
+44
-57
@@ -17,10 +17,7 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
PickRemoteExitNodeDefaultsResponse,
|
||||
QuickStartRemoteExitNodeResponse
|
||||
} from "@server/routers/remoteExitNode/types";
|
||||
import { PickRemoteExitNodeDefaultsResponse } from "@server/routers/remoteExitNode/types";
|
||||
import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import {
|
||||
@@ -30,11 +27,10 @@ import {
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export default function CredentialsPage() {
|
||||
const { env } = useEnvContext();
|
||||
@@ -44,14 +40,7 @@ export default function CredentialsPage() {
|
||||
const t = useTranslations();
|
||||
const { remoteExitNode } = useRemoteExitNodeContext();
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [credentials, setCredentials] =
|
||||
useState<PickRemoteExitNodeDefaultsResponse | null>(null);
|
||||
const [currentRemoteExitNodeId, setCurrentRemoteExitNodeId] = useState<
|
||||
string | null
|
||||
>(remoteExitNode.remoteExitNodeId);
|
||||
const [regeneratedSecret, setRegeneratedSecret] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
@@ -65,28 +54,19 @@ export default function CredentialsPage() {
|
||||
>(`/org/${orgId}/pick-remote-exit-node-defaults`);
|
||||
|
||||
const data = response.data.data;
|
||||
setCredentials(data);
|
||||
|
||||
const rekeyRes = await api.put<
|
||||
AxiosResponse<QuickStartRemoteExitNodeResponse>
|
||||
>(`/re-key/${orgId}/regenerate-remote-exit-node-secret`, {
|
||||
remoteExitNodeId: remoteExitNode.remoteExitNodeId,
|
||||
secret: data.secret,
|
||||
disconnect: shouldDisconnect
|
||||
});
|
||||
|
||||
if (rekeyRes && rekeyRes.status === 200) {
|
||||
const rekeyData = rekeyRes.data.data;
|
||||
if (rekeyData && rekeyData.remoteExitNodeId) {
|
||||
setCurrentRemoteExitNodeId(rekeyData.remoteExitNodeId);
|
||||
setRegeneratedSecret(data.secret);
|
||||
setCredentials({
|
||||
...data,
|
||||
remoteExitNodeId: rekeyData.remoteExitNodeId
|
||||
});
|
||||
setShowCredentialsAlert(true);
|
||||
// Only the secret is rotated; the node keeps its existing ID
|
||||
await api.put(
|
||||
`/re-key/${orgId}/regenerate-remote-exit-node-secret`,
|
||||
{
|
||||
remoteExitNodeId: remoteExitNode.remoteExitNodeId,
|
||||
secret: data.secret,
|
||||
disconnect: shouldDisconnect
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
setRegeneratedSecret(data.secret);
|
||||
setShowCredentialsAlert(true);
|
||||
|
||||
toast({
|
||||
title: t("credentialsSaved"),
|
||||
@@ -112,9 +92,9 @@ export default function CredentialsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const displayRemoteExitNodeId =
|
||||
currentRemoteExitNodeId || remoteExitNode?.remoteExitNodeId || null;
|
||||
const displayRemoteExitNodeId = remoteExitNode?.remoteExitNodeId || null;
|
||||
const displaySecret = regeneratedSecret || null;
|
||||
const showEndpoint = build !== "saas";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -129,21 +109,19 @@ export default function CredentialsPage() {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.rotateCredentials}
|
||||
/>
|
||||
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("endpoint")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={env.app.dashboardUrl}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSections cols={showEndpoint ? 3 : 2}>
|
||||
{showEndpoint && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("endpoint")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={env.app.dashboardUrl}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("remoteExitNodeId")}
|
||||
@@ -174,6 +152,21 @@ export default function CredentialsPage() {
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
|
||||
{displaySecret && (
|
||||
<div className="mt-4">
|
||||
<CopyTextBox
|
||||
text={`managed:
|
||||
id: "${displayRemoteExitNodeId}"
|
||||
secret: "${displaySecret}"${
|
||||
showEndpoint
|
||||
? `
|
||||
endpoint: "${env.app.dashboardUrl}"`
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCredentialsAlert && displaySecret && (
|
||||
<Alert variant="neutral" className="mt-4">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
@@ -196,9 +189,6 @@ export default function CredentialsPage() {
|
||||
setShouldDisconnect(false);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
disabled={
|
||||
!isPaidUser(tierMatrix.rotateCredentials)
|
||||
}
|
||||
>
|
||||
{t("regenerateCredentialsButton")}
|
||||
</Button>
|
||||
@@ -207,9 +197,6 @@ export default function CredentialsPage() {
|
||||
setShouldDisconnect(true);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
disabled={
|
||||
!isPaidUser(tierMatrix.rotateCredentials)
|
||||
}
|
||||
>
|
||||
{t("remoteExitNodeRegenerateAndDisconnect")}
|
||||
</Button>
|
||||
|
||||
@@ -34,13 +34,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
const t = await getTranslations();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t("credentials"),
|
||||
href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials"
|
||||
},
|
||||
{
|
||||
title: "Networking",
|
||||
href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/networking"
|
||||
},
|
||||
{
|
||||
title: t("credentials"),
|
||||
href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+1
@@ -186,6 +186,7 @@ export default function NetworkingPage() {
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{" "}
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
|
||||
@@ -37,6 +37,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export default function CreateRemoteExitNodePage() {
|
||||
const { env } = useEnvContext();
|
||||
@@ -315,7 +316,12 @@ export default function CreateRemoteExitNodePage() {
|
||||
<CopyTextBox
|
||||
text={`managed:
|
||||
id: "${defaults?.remoteExitNodeId}"
|
||||
secret: "${defaults?.secret}"`}
|
||||
secret: "${defaults?.secret}"${
|
||||
build !== "saas"
|
||||
? `
|
||||
endpoint: "${env.app.dashboardUrl}"`
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -28,10 +28,7 @@ import {
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { OlmInstallCommands } from "@app/components/olm-install-commands";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
export default function CredentialsPage() {
|
||||
const { env } = useEnvContext();
|
||||
@@ -53,8 +50,6 @@ export default function CredentialsPage() {
|
||||
const [showCredentialsAlert, setShowCredentialsAlert] = useState(false);
|
||||
const [shouldDisconnect, setShouldDisconnect] = useState(true);
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const handleConfirmRegenerate = async () => {
|
||||
try {
|
||||
const res = await api.get(`/org/${orgId}/pick-client-defaults`);
|
||||
@@ -119,10 +114,6 @@ export default function CredentialsPage() {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.rotateCredentials}
|
||||
/>
|
||||
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
@@ -182,9 +173,6 @@ export default function CredentialsPage() {
|
||||
setShouldDisconnect(false);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
disabled={
|
||||
!isPaidUser(tierMatrix.rotateCredentials)
|
||||
}
|
||||
>
|
||||
{t("regenerateCredentialsButton")}
|
||||
</Button>
|
||||
@@ -193,9 +181,6 @@ export default function CredentialsPage() {
|
||||
setShouldDisconnect(true);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
disabled={
|
||||
!isPaidUser(tierMatrix.rotateCredentials)
|
||||
}
|
||||
>
|
||||
{t("clientRegenerateAndDisconnect")}
|
||||
</Button>
|
||||
|
||||
@@ -209,10 +209,6 @@ export default function GeneralPage() {
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
{showRoles && (
|
||||
<PaidFeaturesAlert tiers={tierMatrix.fullRbac} />
|
||||
)}
|
||||
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -257,15 +253,21 @@ export default function GeneralPage() {
|
||||
/>
|
||||
|
||||
{showRoles && (
|
||||
<OrgRolesTagField
|
||||
form={form}
|
||||
name="roles"
|
||||
orgId={client.orgId}
|
||||
supportsMultipleRolesPerUser={true}
|
||||
showMultiRolePaywallMessage={false}
|
||||
paywallMessage=""
|
||||
disabled={!canAssignRoles}
|
||||
/>
|
||||
<>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.fullRbac}
|
||||
/>
|
||||
|
||||
<OrgRolesTagField
|
||||
form={form}
|
||||
name="roles"
|
||||
orgId={client.orgId}
|
||||
supportsMultipleRolesPerUser={true}
|
||||
showMultiRolePaywallMessage={false}
|
||||
paywallMessage=""
|
||||
disabled={!canAssignRoles}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SelectedResource } from "@app/components/resource-selector";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import { GetDomainResponse } from "@server/routers/domain/getDomain";
|
||||
import type { ListRedirectsResponse } from "@server/routers/redirect";
|
||||
import { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||
@@ -127,6 +128,50 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) {
|
||||
baseDomain: redirect.baseDomain
|
||||
}));
|
||||
|
||||
// Prefetched in one batched call so the table doesn't fire a separate
|
||||
// certificate request per visible row once it mounts on the client.
|
||||
const certDomains = Array.from(
|
||||
new Set(
|
||||
redirectRows
|
||||
.map((redirect) => {
|
||||
if (redirect.resourceId) {
|
||||
if (
|
||||
redirect.resourceSsl &&
|
||||
redirect.resourceFullDomain
|
||||
) {
|
||||
return redirect.resourceFullDomain;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!redirect.ssl || !redirect.baseDomain) {
|
||||
return null;
|
||||
}
|
||||
return [redirect.subdomain, redirect.baseDomain]
|
||||
.filter(Boolean)
|
||||
.join(".");
|
||||
})
|
||||
.filter((domain): domain is string => Boolean(domain))
|
||||
)
|
||||
);
|
||||
|
||||
let initialCertificates: GetBatchedCertificateResponse | undefined;
|
||||
if (certDomains.length > 0) {
|
||||
try {
|
||||
const certSearchParams = new URLSearchParams(
|
||||
certDomains.map((domain) => ["domains", domain])
|
||||
);
|
||||
const certRes = await internal.get<
|
||||
AxiosResponse<GetBatchedCertificateResponse>
|
||||
>(
|
||||
`/org/${orgId}/batched-certificates?${certSearchParams.toString()}`,
|
||||
header
|
||||
);
|
||||
initialCertificates = certRes.data.data;
|
||||
} catch {
|
||||
// leave undefined so each row falls back to fetching its own
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
@@ -144,6 +189,7 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) {
|
||||
}}
|
||||
initialFilterResource={initialFilterResource}
|
||||
initialFilterDomain={initialFilterDomain}
|
||||
initialCertificates={initialCertificates}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { build } from "@server/build";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||
import type { AxiosResponse } from "axios";
|
||||
@@ -120,7 +119,7 @@ export default async function ClientResourcesPage(
|
||||
);
|
||||
|
||||
let initialCertificates: GetBatchedCertificateResponse | undefined;
|
||||
if (build !== "oss" && certDomains.length > 0) {
|
||||
if (certDomains.length > 0) {
|
||||
try {
|
||||
const certSearchParams = new URLSearchParams(
|
||||
certDomains.map((domain) => ["domains", domain])
|
||||
|
||||
@@ -127,6 +127,7 @@ function ProxyResourceHttpForm({
|
||||
message: t("proxyErrorInvalidHeader")
|
||||
}
|
||||
),
|
||||
forwardIdentityHeaders: z.boolean(),
|
||||
requestHeaders: z
|
||||
.array(z.object({ name: z.string(), value: z.string() }))
|
||||
.nullable(),
|
||||
@@ -143,6 +144,7 @@ function ProxyResourceHttpForm({
|
||||
ssl: resource.ssl,
|
||||
tlsServerName: resource.tlsServerName || "",
|
||||
setHostHeader: resource.setHostHeader || "",
|
||||
forwardIdentityHeaders: resource.forwardIdentityHeaders,
|
||||
requestHeaders: resource.requestHeaders,
|
||||
responseHeaders: resource.responseHeaders
|
||||
},
|
||||
@@ -184,6 +186,7 @@ function ProxyResourceHttpForm({
|
||||
ssl: data.ssl,
|
||||
tlsServerName: data.tlsServerName || null,
|
||||
setHostHeader: data.setHostHeader || null,
|
||||
forwardIdentityHeaders: data.forwardIdentityHeaders,
|
||||
requestHeaders: data.requestHeaders || null,
|
||||
responseHeaders: data.responseHeaders || null
|
||||
}
|
||||
@@ -207,6 +210,7 @@ function ProxyResourceHttpForm({
|
||||
ssl: data.ssl,
|
||||
tlsServerName: data.tlsServerName || null,
|
||||
setHostHeader: data.setHostHeader || null,
|
||||
forwardIdentityHeaders: data.forwardIdentityHeaders,
|
||||
requestHeaders: data.requestHeaders || null,
|
||||
responseHeaders: data.responseHeaders || null
|
||||
});
|
||||
@@ -380,6 +384,32 @@ function ProxyResourceHttpForm({
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="forwardIdentityHeaders"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="forward-identity-headers-toggle"
|
||||
label={t(
|
||||
"proxyForwardIdentityHeaders"
|
||||
)}
|
||||
description={t(
|
||||
"proxyForwardIdentityHeadersDescription"
|
||||
)}
|
||||
checked={field.value}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -114,13 +114,6 @@ 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") {
|
||||
|
||||
@@ -1,490 +0,0 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import PublicResourcesBanner from "@app/components/PublicResourcesBanner";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { build } from "@server/build";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import type { GetOrgResponse } from "@server/routers/org";
|
||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||
@@ -99,6 +98,7 @@ export default async function ProxyResourcesPage(
|
||||
resource.pincodeId !== null ||
|
||||
resource.passwordId !== null ||
|
||||
resource.whitelist ||
|
||||
resource.mtls ||
|
||||
resource.headerAuthId
|
||||
? "protected"
|
||||
: "not_protected",
|
||||
@@ -131,7 +131,7 @@ export default async function ProxyResourcesPage(
|
||||
);
|
||||
|
||||
let initialCertificates: GetBatchedCertificateResponse | undefined;
|
||||
if (build !== "oss" && certDomains.length > 0) {
|
||||
if (certDomains.length > 0) {
|
||||
try {
|
||||
const certSearchParams = new URLSearchParams(
|
||||
certDomains.map((domain) => ["domains", domain])
|
||||
|
||||
@@ -35,10 +35,7 @@ import {
|
||||
generateObfuscatedWireGuardConfig
|
||||
} from "@app/lib/wireguard";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { NewtSiteInstallCommands } from "@app/components/newt-install-commands";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { productUpdatesQueries } from "@app/lib/queries";
|
||||
@@ -67,8 +64,6 @@ export default function CredentialsPage() {
|
||||
const [loadingDefaults, setLoadingDefaults] = useState(false);
|
||||
const [shouldDisconnect, setShouldDisconnect] = useState(true);
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const { data: latestVersions } = useQuery(
|
||||
productUpdatesQueries.latestVersion(true)
|
||||
);
|
||||
@@ -206,10 +201,6 @@ export default function CredentialsPage() {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.rotateCredentials}
|
||||
/>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
@@ -280,11 +271,6 @@ export default function CredentialsPage() {
|
||||
setShouldDisconnect(false);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.rotateCredentials
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("regenerateCredentialsButton")}
|
||||
</Button>
|
||||
@@ -293,11 +279,6 @@ export default function CredentialsPage() {
|
||||
setShouldDisconnect(true);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.rotateCredentials
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("siteRegenerateAndDisconnect")}
|
||||
</Button>
|
||||
@@ -324,10 +305,6 @@ export default function CredentialsPage() {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.rotateCredentials}
|
||||
/>
|
||||
|
||||
<SettingsSectionBody>
|
||||
{!loadingDefaults && (
|
||||
<>
|
||||
@@ -393,14 +370,7 @@ export default function CredentialsPage() {
|
||||
</SettingsSectionBody>
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={() => setModalOpen(true)}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.rotateCredentials
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button onClick={() => setModalOpen(true)}>
|
||||
{t("siteRegenerateAndDisconnect")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
|
||||
@@ -536,6 +536,53 @@ export default function LicensePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{t("licenseUsageMachines")}
|
||||
</div>
|
||||
<div className="text-2xl">
|
||||
{t("licenseMachinesUsed", {
|
||||
count: licenseStatus?.usedMachines || 0
|
||||
})}
|
||||
</div>
|
||||
{licenseStatus?.maxMachines ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("licenseMachinesUsedMax", {
|
||||
usedMachines:
|
||||
licenseStatus.usedMachines ||
|
||||
0,
|
||||
maxMachines:
|
||||
licenseStatus.maxMachines
|
||||
})}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{Math.round(
|
||||
((licenseStatus.usedMachines ||
|
||||
0) /
|
||||
licenseStatus.maxMachines) *
|
||||
100
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
((licenseStatus.usedMachines ||
|
||||
0) /
|
||||
licenseStatus.maxMachines) *
|
||||
100
|
||||
}
|
||||
className="h-5"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("licenseNoMachineLimit")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<SettingsSection>
|
||||
|
||||
@@ -255,6 +255,27 @@ export default function GenerateLicenseKeysTable({
|
||||
return sites === -1 ? "∞" : sites;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "machines",
|
||||
friendlyName: t("machineClients"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("machineClients")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const machines = row.original.machines;
|
||||
return machines === -1 ? "∞" : machines;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "terminateAt",
|
||||
friendlyName: t("licenseTableValidUntil"),
|
||||
|
||||
@@ -160,6 +160,30 @@ export function LicenseKeysDataTable({
|
||||
return quantity < 0 ? t("licenseUnlimited") : quantity;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "quantity_3",
|
||||
friendlyName: t("machineClients"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("machineClients")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const quantity = row.original.quantity_3;
|
||||
if (quantity === undefined) {
|
||||
return "-";
|
||||
}
|
||||
return quantity < 0 ? t("licenseUnlimited") : quantity;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "terminateAt",
|
||||
friendlyName: t("licenseTableValidUntil"),
|
||||
|
||||
@@ -36,7 +36,8 @@ function getActionsCategories(root: boolean) {
|
||||
[t("createOrgUser")]: "createOrgUser",
|
||||
[t("actionApplyBlueprint")]: "applyBlueprint",
|
||||
[t("actionListBlueprints")]: "listBlueprints",
|
||||
[t("actionGetBlueprint")]: "getBlueprint"
|
||||
[t("actionGetBlueprint")]: "getBlueprint",
|
||||
[t("actionReGenerateSecret")]: "reGenerateSecret"
|
||||
},
|
||||
|
||||
Domain: {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||
import {
|
||||
ResourceSelector,
|
||||
type SelectedResource
|
||||
@@ -40,6 +41,7 @@ import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
@@ -113,6 +115,8 @@ type RedirectsTableProps = {
|
||||
rowCount: number;
|
||||
initialFilterResource?: SelectedResource | null;
|
||||
initialFilterDomain?: RedirectFilterDomain | null;
|
||||
/** Certificates prefetched on the server, keyed by full domain. */
|
||||
initialCertificates?: GetBatchedCertificateResponse;
|
||||
};
|
||||
|
||||
export default function RedirectsTable({
|
||||
@@ -121,7 +125,8 @@ export default function RedirectsTable({
|
||||
pagination,
|
||||
rowCount,
|
||||
initialFilterResource = null,
|
||||
initialFilterDomain = null
|
||||
initialFilterDomain = null,
|
||||
initialCertificates
|
||||
}: RedirectsTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
@@ -460,7 +465,36 @@ export default function RedirectsTable({
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return <CopyToClipboard text={source} isLink={false} />;
|
||||
const certDomainId = redirect.resourceId
|
||||
? redirect.resourceDomainId
|
||||
: redirect.domainId;
|
||||
const certHostname = redirect.resourceId
|
||||
? redirect.resourceFullDomain
|
||||
: domainHost;
|
||||
const showHttpsCertIndicator =
|
||||
sourceSsl &&
|
||||
certHostname != null &&
|
||||
certHostname !== "" &&
|
||||
certDomainId != null &&
|
||||
certDomainId !== "";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{showHttpsCertIndicator ? (
|
||||
<ResourceAccessCertIndicator
|
||||
orgId={orgId}
|
||||
domainId={certDomainId}
|
||||
fullDomain={certHostname}
|
||||
initialCertValue={
|
||||
initialCertificates?.[certHostname]
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<div className="">
|
||||
<CopyToClipboard text={source} isLink={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -647,7 +681,8 @@ export default function RedirectsTable({
|
||||
domainFilterOpen,
|
||||
resourceFilterOpen,
|
||||
selectedDomain,
|
||||
selectedResource
|
||||
selectedResource,
|
||||
initialCertificates
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import { useTranslations } from "next-intl";
|
||||
import CertificateStatus from "@app/components/CertificateStatus";
|
||||
import { toUnicode } from "punycode";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
@@ -118,7 +117,8 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
authInfo.pincode ||
|
||||
authInfo.sso ||
|
||||
authInfo.whitelist ||
|
||||
authInfo.headerAuth ? (
|
||||
authInfo.headerAuth ||
|
||||
authInfo.mtls ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<ShieldCheck className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||
<span>{t("protected")}</span>
|
||||
|
||||
@@ -93,7 +93,9 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
rules: [],
|
||||
password: null,
|
||||
headerAuth: null,
|
||||
pincode: null
|
||||
pincode: null,
|
||||
mtlsEnabled: false,
|
||||
mtlsCertificates: []
|
||||
}
|
||||
});
|
||||
|
||||
@@ -122,6 +124,16 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
// email OTP
|
||||
emailWhitelistEnabled: payload.emailWhitelistEnabled,
|
||||
emails: payload.emails.map((email) => email.text),
|
||||
// mTLS
|
||||
mtlsEnabled:
|
||||
payload.mtlsEnabled &&
|
||||
payload.mtlsCertificates.length > 0,
|
||||
mtlsCaCertificates: payload.mtlsEnabled
|
||||
? payload.mtlsCertificates.map((c) => ({
|
||||
certificate: c.certificate,
|
||||
...(c.name ? { name: c.name } : {})
|
||||
}))
|
||||
: [],
|
||||
// rules
|
||||
applyRules: payload.applyRules,
|
||||
rules: payload.rules
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { PolicyAuthMethodId } from "./policy-auth-method-id";
|
||||
import {
|
||||
getEmailWhitelistSummary,
|
||||
getHeaderAuthSummary,
|
||||
getMtlsSummary,
|
||||
getPasscodeSummary,
|
||||
getPincodeSummary
|
||||
} from "./policy-auth-summaries";
|
||||
@@ -26,11 +27,21 @@ export type PolicyAuthOtherMethodsSectionProps = {
|
||||
emailCount: number;
|
||||
emailEnabled: boolean;
|
||||
disabled?: boolean;
|
||||
// false shows only mTLS, e.g. for inference resources
|
||||
credentialMethods?: boolean;
|
||||
onConfigure: (method: PolicyAuthMethodId) => void;
|
||||
onTogglePincode: (active: boolean) => void;
|
||||
onTogglePasscode: (active: boolean) => void;
|
||||
onToggleEmail: (active: boolean) => void;
|
||||
onToggleHeaderAuth: (active: boolean) => void;
|
||||
mtls?: {
|
||||
active: boolean;
|
||||
certificateCount: number;
|
||||
sslEnabled: boolean;
|
||||
disabled?: boolean;
|
||||
onConfigure: () => void;
|
||||
onToggle: (active: boolean) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function PolicyAuthOtherMethodsSection({
|
||||
@@ -42,11 +53,13 @@ export function PolicyAuthOtherMethodsSection({
|
||||
emailCount,
|
||||
emailEnabled,
|
||||
disabled,
|
||||
credentialMethods = true,
|
||||
onConfigure,
|
||||
onTogglePincode,
|
||||
onTogglePasscode,
|
||||
onToggleEmail,
|
||||
onToggleHeaderAuth
|
||||
onToggleHeaderAuth,
|
||||
mtls
|
||||
}: PolicyAuthOtherMethodsSectionProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -64,55 +77,81 @@ export function PolicyAuthOtherMethodsSection({
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<div className="flex flex-col gap-3">
|
||||
<PolicyAuthMethodRow
|
||||
id="pincode"
|
||||
title={t("policyAuthPincodeTitle")}
|
||||
description={t("policyAuthPincodeDescription")}
|
||||
summary={getPincodeSummary({ t })}
|
||||
active={pinActive}
|
||||
onConfigure={() => onConfigure("pincode")}
|
||||
onToggle={onTogglePincode}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{credentialMethods && (
|
||||
<>
|
||||
<PolicyAuthMethodRow
|
||||
id="pincode"
|
||||
title={t("policyAuthPincodeTitle")}
|
||||
description={t("policyAuthPincodeDescription")}
|
||||
summary={getPincodeSummary({ t })}
|
||||
active={pinActive}
|
||||
onConfigure={() => onConfigure("pincode")}
|
||||
onToggle={onTogglePincode}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="passcode"
|
||||
title={t("policyAuthPasscodeTitle")}
|
||||
description={t("policyAuthPasscodeDescription")}
|
||||
summary={getPasscodeSummary({ t })}
|
||||
active={passcodeActive}
|
||||
onConfigure={() => onConfigure("passcode")}
|
||||
onToggle={onTogglePasscode}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<PolicyAuthMethodRow
|
||||
id="passcode"
|
||||
title={t("policyAuthPasscodeTitle")}
|
||||
description={t("policyAuthPasscodeDescription")}
|
||||
summary={getPasscodeSummary({ t })}
|
||||
active={passcodeActive}
|
||||
onConfigure={() => onConfigure("passcode")}
|
||||
onToggle={onTogglePasscode}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="email"
|
||||
title={t("policyAuthEmailTitle")}
|
||||
description={t("policyAuthEmailDescription")}
|
||||
summary={getEmailWhitelistSummary({
|
||||
t,
|
||||
count: emailCount
|
||||
})}
|
||||
active={emailWhitelistEnabled}
|
||||
onConfigure={() => onConfigure("email")}
|
||||
onToggle={onToggleEmail}
|
||||
disabled={disabled || !emailEnabled}
|
||||
/>
|
||||
<PolicyAuthMethodRow
|
||||
id="email"
|
||||
title={t("policyAuthEmailTitle")}
|
||||
description={t("policyAuthEmailDescription")}
|
||||
summary={getEmailWhitelistSummary({
|
||||
t,
|
||||
count: emailCount
|
||||
})}
|
||||
active={emailWhitelistEnabled}
|
||||
onConfigure={() => onConfigure("email")}
|
||||
onToggle={onToggleEmail}
|
||||
disabled={disabled || !emailEnabled}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="header-auth"
|
||||
title={t("policyAuthHeaderAuthTitle")}
|
||||
description={t("policyAuthHeaderAuthDescription")}
|
||||
summary={getHeaderAuthSummary({
|
||||
t,
|
||||
headerName: headerAuthUser
|
||||
})}
|
||||
active={headerAuthActive}
|
||||
onConfigure={() => onConfigure("headerAuth")}
|
||||
onToggle={onToggleHeaderAuth}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<PolicyAuthMethodRow
|
||||
id="header-auth"
|
||||
title={t("policyAuthHeaderAuthTitle")}
|
||||
description={t(
|
||||
"policyAuthHeaderAuthDescription"
|
||||
)}
|
||||
summary={getHeaderAuthSummary({
|
||||
t,
|
||||
headerName: headerAuthUser
|
||||
})}
|
||||
active={headerAuthActive}
|
||||
onConfigure={() => onConfigure("headerAuth")}
|
||||
onToggle={onToggleHeaderAuth}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mtls && (
|
||||
<PolicyAuthMethodRow
|
||||
id="mtls"
|
||||
title={t("policyAuthMtlsTitle")}
|
||||
description={
|
||||
mtls.sslEnabled
|
||||
? t("policyAuthMtlsDescription")
|
||||
: t("mtlsRequiresSsl")
|
||||
}
|
||||
summary={getMtlsSummary({
|
||||
t,
|
||||
count: mtls.certificateCount
|
||||
})}
|
||||
active={mtls.active}
|
||||
onConfigure={mtls.onConfigure}
|
||||
onToggle={mtls.onToggle}
|
||||
disabled={mtls.disabled || !mtls.sslEnabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { RolesSelector } from "@app/components/roles-selector";
|
||||
import { UsersSelector } from "@app/components/users-selector";
|
||||
import { FormField } from "@app/components/ui/form";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { type UseFormReturn, useWatch } from "react-hook-form";
|
||||
@@ -23,8 +25,12 @@ import {
|
||||
} from "./PolicyAuthMethodCredenzas";
|
||||
import { PolicyAuthOtherMethodsSection } from "./PolicyAuthOtherMethodsSection";
|
||||
import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection";
|
||||
import { MtlsCertificatesCredenza } from "./PolicyMtlsCertificatesCredenza";
|
||||
import type { PolicyAuthMethodId } from "./policy-auth-method-id";
|
||||
|
||||
const PEM_CERTIFICATE_REGEX =
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g;
|
||||
|
||||
export type PolicyAuthStackSectionCreateProps = {
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
orgId: string;
|
||||
@@ -39,6 +45,7 @@ export function PolicyAuthStackSectionCreate({
|
||||
emailEnabled
|
||||
}: PolicyAuthStackSectionCreateProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const [editingMethod, setEditingMethod] =
|
||||
useState<PolicyAuthMethodId | null>(null);
|
||||
|
||||
@@ -62,13 +69,76 @@ export function PolicyAuthStackSectionCreate({
|
||||
});
|
||||
const emails =
|
||||
useWatch({ control: parentForm.control, name: "emails" }) ?? [];
|
||||
const mtlsEnabled = useWatch({
|
||||
control: parentForm.control,
|
||||
name: "mtlsEnabled"
|
||||
});
|
||||
const mtlsCertificates =
|
||||
useWatch({ control: parentForm.control, name: "mtlsCertificates" }) ??
|
||||
[];
|
||||
|
||||
const passcodeActive = Boolean(password);
|
||||
const pinActive = Boolean(pincode);
|
||||
const headerAuthActive = Boolean(headerAuth);
|
||||
const mtlsSupported = !env.flags.disableEnterpriseFeatures;
|
||||
|
||||
const closeCredenza = () => setEditingMethod(null);
|
||||
|
||||
// The policy doesn't exist yet, so certificates are held in the form and
|
||||
// sent with the create request, which parses and validates them.
|
||||
async function addMtlsCertificates(pem: string, name: string | null) {
|
||||
const showError = (description: string) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("mtlsCertificateErrorAdd"),
|
||||
description
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
if (/PRIVATE KEY/.test(pem)) {
|
||||
return showError(t("mtlsCertificatePrivateKey"));
|
||||
}
|
||||
const blocks = (pem.match(PEM_CERTIFICATE_REGEX) ?? []).map(
|
||||
(block) => block.trim() + "\n"
|
||||
);
|
||||
if (blocks.length === 0) {
|
||||
return showError(t("mtlsCertificateInvalidPem"));
|
||||
}
|
||||
|
||||
const existing = new Set(mtlsCertificates.map((c) => c.certificate));
|
||||
const newBlocks = [...new Set(blocks)].filter(
|
||||
(block) => !existing.has(block)
|
||||
);
|
||||
if (newBlocks.length === 0) {
|
||||
return showError(t("mtlsCertificateDuplicate"));
|
||||
}
|
||||
|
||||
let nextId =
|
||||
Math.max(0, ...mtlsCertificates.map((c) => c.mtlsCertificateId)) +
|
||||
1;
|
||||
parentForm.setValue("mtlsCertificates", [
|
||||
...mtlsCertificates,
|
||||
...newBlocks.map((certificate) => ({
|
||||
mtlsCertificateId: nextId++,
|
||||
// a label only makes sense for a single certificate
|
||||
name: newBlocks.length === 1 ? name : null,
|
||||
certificate
|
||||
}))
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function removeMtlsCertificate(mtlsCertificateId: number) {
|
||||
const remaining = mtlsCertificates.filter(
|
||||
(c) => c.mtlsCertificateId !== mtlsCertificateId
|
||||
);
|
||||
parentForm.setValue("mtlsCertificates", remaining);
|
||||
if (remaining.length === 0) {
|
||||
parentForm.setValue("mtlsEnabled", false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = (
|
||||
method: PolicyAuthMethodId,
|
||||
active: boolean,
|
||||
@@ -177,6 +247,34 @@ export function PolicyAuthStackSectionCreate({
|
||||
parentForm.setValue("headerAuth", null)
|
||||
)
|
||||
}
|
||||
mtls={
|
||||
mtlsSupported
|
||||
? {
|
||||
active: Boolean(mtlsEnabled),
|
||||
certificateCount: mtlsCertificates.length,
|
||||
// applies to the policy's resources
|
||||
// that have SSL enabled
|
||||
sslEnabled: true,
|
||||
onConfigure: () =>
|
||||
setEditingMethod("mtls"),
|
||||
onToggle: (active) =>
|
||||
handleToggle(
|
||||
"mtls",
|
||||
active,
|
||||
() =>
|
||||
parentForm.setValue(
|
||||
"mtlsEnabled",
|
||||
false
|
||||
),
|
||||
() =>
|
||||
parentForm.setValue(
|
||||
"mtlsEnabled",
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
|
||||
@@ -229,6 +327,34 @@ export function PolicyAuthStackSectionCreate({
|
||||
parentForm.setValue("headerAuth", value);
|
||||
}}
|
||||
/>
|
||||
|
||||
{mtlsSupported && (
|
||||
<MtlsCertificatesCredenza
|
||||
open={editingMethod === "mtls"}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
closeCredenza();
|
||||
// mTLS can't be enabled without a trusted CA.
|
||||
if (mtlsCertificates.length === 0) {
|
||||
parentForm.setValue("mtlsEnabled", false);
|
||||
}
|
||||
}}
|
||||
certificates={mtlsCertificates.map((c, index) => ({
|
||||
mtlsCertificateId: c.mtlsCertificateId,
|
||||
name:
|
||||
c.name ??
|
||||
t("mtlsPendingCertificate", {
|
||||
number: index + 1
|
||||
}),
|
||||
subject: null,
|
||||
issuer: null,
|
||||
notAfter: null
|
||||
}))}
|
||||
isLoading={false}
|
||||
onAdd={addMtlsCertificates}
|
||||
onRemove={removeMtlsCertificate}
|
||||
/>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Button } from "@app/components/ui/button";
|
||||
import { Form, FormField } from "@app/components/ui/form";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePolicyMtls } from "@app/hooks/usePolicyMtls";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
@@ -31,14 +32,7 @@ import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toUnicode } from "punycode";
|
||||
import {
|
||||
useActionState,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { createPolicySchema } from ".";
|
||||
import {
|
||||
@@ -49,6 +43,7 @@ import {
|
||||
} from "./PolicyAuthMethodCredenzas";
|
||||
import { PolicyAuthOtherMethodsSection } from "./PolicyAuthOtherMethodsSection";
|
||||
import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection";
|
||||
import { PolicyMtlsCertificatesCredenza } from "./PolicyMtlsCertificatesCredenza";
|
||||
import type { PolicyAuthMethodId } from "./policy-auth-method-id";
|
||||
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
|
||||
import z from "zod";
|
||||
@@ -258,6 +253,10 @@ export function PolicyAuthStackSectionEdit({
|
||||
const [editingMethod, setEditingMethod] =
|
||||
useState<PolicyAuthMethodId | null>(null);
|
||||
|
||||
const mtls = usePolicyMtls();
|
||||
const canSaveMtls =
|
||||
mtls.isDirty && !(mtls.enabled && !mtls.hasCertificates);
|
||||
|
||||
const sso = useWatch({ control: form.control, name: "sso" });
|
||||
const skipToIdpId = useWatch({
|
||||
control: form.control,
|
||||
@@ -283,9 +282,23 @@ export function PolicyAuthStackSectionEdit({
|
||||
}
|
||||
}, [isInferenceResource, form]);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSavingOverlay, setIsSavingOverlay] = useState(false);
|
||||
|
||||
// Not using <form action>: React 19 resets the form after the action
|
||||
// completes, and Radix Switch listens for that reset and flips controlled
|
||||
// switches (e.g. SSO) back to their initial value.
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit();
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (readonly && !isResourceOverlay) return;
|
||||
|
||||
@@ -416,6 +429,10 @@ export function PolicyAuthStackSectionEdit({
|
||||
policyUpdates.emailWhitelistEnabled = payload.emailWhitelistEnabled;
|
||||
}
|
||||
|
||||
if (canSaveMtls) {
|
||||
requests.push(mtls.save().catch(handleError));
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.all(requests);
|
||||
if (results.every((res) => res && res.status === 200)) {
|
||||
@@ -437,15 +454,22 @@ export function PolicyAuthStackSectionEdit({
|
||||
|
||||
updatePolicy(policyUpdates);
|
||||
|
||||
// Traefik only enforces mTLS with a trusted CA and SSL.
|
||||
const mtlsEnforced =
|
||||
mtls.supported &&
|
||||
mtls.enabled &&
|
||||
mtls.hasCertificates &&
|
||||
mtls.sslEnabled;
|
||||
resourceContext?.updateAuthInfo(
|
||||
isInferenceResource
|
||||
? { sso: true }
|
||||
? { sso: true, mtls: mtlsEnforced }
|
||||
: {
|
||||
sso: payload.sso,
|
||||
whitelist: payload.emailWhitelistEnabled,
|
||||
password: passcodeOnServerRef.current,
|
||||
pincode: pincodeOnServerRef.current,
|
||||
headerAuth: headerAuthOnServerRef.current
|
||||
headerAuth: headerAuthOnServerRef.current,
|
||||
mtls: mtlsEnforced
|
||||
}
|
||||
);
|
||||
|
||||
@@ -537,7 +561,7 @@ export function PolicyAuthStackSectionEdit({
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form action={formAction}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
@@ -646,8 +670,9 @@ export function PolicyAuthStackSectionEdit({
|
||||
}
|
||||
/>
|
||||
|
||||
{!isInferenceResource && (
|
||||
{(!isInferenceResource || mtls.supported) && (
|
||||
<PolicyAuthOtherMethodsSection
|
||||
credentialMethods={!isInferenceResource}
|
||||
pinActive={pinActive}
|
||||
passcodeActive={passcodeActive}
|
||||
emailWhitelistEnabled={Boolean(
|
||||
@@ -692,6 +717,32 @@ export function PolicyAuthStackSectionEdit({
|
||||
}
|
||||
)
|
||||
}
|
||||
mtls={
|
||||
mtls.supported
|
||||
? {
|
||||
active: mtls.enabled,
|
||||
certificateCount:
|
||||
mtls.certificates.length,
|
||||
sslEnabled: mtls.sslEnabled,
|
||||
disabled: authReadonly,
|
||||
onConfigure: () =>
|
||||
openMethodEditor("mtls"),
|
||||
onToggle: (active) =>
|
||||
handleToggle(
|
||||
"mtls",
|
||||
active,
|
||||
() =>
|
||||
mtls.setEnabled(
|
||||
false
|
||||
),
|
||||
() =>
|
||||
mtls.setEnabled(
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
@@ -772,6 +823,29 @@ export function PolicyAuthStackSectionEdit({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mtls.supported && (
|
||||
<PolicyMtlsCertificatesCredenza
|
||||
open={editingMethod === "mtls"}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
closeCredenza();
|
||||
// mTLS can't be enabled without a trusted CA.
|
||||
if (!mtls.hasCertificates) {
|
||||
mtls.setEnabled(false);
|
||||
}
|
||||
}}
|
||||
resourcePolicyId={policy.resourcePolicyId}
|
||||
certificates={mtls.certificates}
|
||||
isLoading={mtls.isLoadingCertificates}
|
||||
onMtlsDisabled={() => {
|
||||
updatePolicy({ mtlsEnabled: false });
|
||||
resourceContext?.updateAuthInfo({
|
||||
mtls: false
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { Textarea } from "@app/components/ui/textarea";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import type {
|
||||
CreateResourcePolicyMtlsCertificateResponse,
|
||||
DeleteResourcePolicyMtlsCertificateResponse,
|
||||
ListResourcePolicyMtlsCertificatesResponse
|
||||
} from "@server/routers/policy";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { FileKey2, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const EXPIRES_SOON_MS = 30 * DAY_MS;
|
||||
|
||||
export type MtlsCertificateListItem = Pick<
|
||||
ListResourcePolicyMtlsCertificatesResponse["certificates"][number],
|
||||
"mtlsCertificateId" | "name" | "subject" | "issuer" | "notAfter"
|
||||
>;
|
||||
|
||||
type PolicyMtlsCertificatesCredenzaProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
resourcePolicyId: number;
|
||||
certificates: ListResourcePolicyMtlsCertificatesResponse["certificates"];
|
||||
isLoading: boolean;
|
||||
// called when removing the last CA certificate switched mTLS off
|
||||
onMtlsDisabled: () => void;
|
||||
};
|
||||
|
||||
/** Manages the trusted CAs of an existing policy through the API. */
|
||||
export function PolicyMtlsCertificatesCredenza({
|
||||
resourcePolicyId,
|
||||
onMtlsDisabled,
|
||||
...props
|
||||
}: PolicyMtlsCertificatesCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
async function invalidateCertificates() {
|
||||
await queryClient.invalidateQueries(
|
||||
orgQueries.resourcePolicyMtlsCertificates({ resourcePolicyId })
|
||||
);
|
||||
}
|
||||
|
||||
async function onAdd(pem: string, name: string | null) {
|
||||
try {
|
||||
await api.put<
|
||||
AxiosResponse<CreateResourcePolicyMtlsCertificateResponse>
|
||||
>(`/resource-policy/${resourcePolicyId}/mtls/certificate`, {
|
||||
certificate: pem,
|
||||
...(name ? { name } : {})
|
||||
});
|
||||
await invalidateCertificates();
|
||||
toast({
|
||||
title: t("mtlsCertificateAdded"),
|
||||
description: t("mtlsCertificateAddedDescription")
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("mtlsCertificateErrorAdd"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("mtlsCertificateErrorAddDescription")
|
||||
)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemove(mtlsCertificateId: number) {
|
||||
try {
|
||||
const res = await api.delete<
|
||||
AxiosResponse<DeleteResourcePolicyMtlsCertificateResponse>
|
||||
>(
|
||||
`/resource-policy/${resourcePolicyId}/mtls/certificate/${mtlsCertificateId}`
|
||||
);
|
||||
await invalidateCertificates();
|
||||
|
||||
if (res.data.data.mtlsDisabled) {
|
||||
onMtlsDisabled();
|
||||
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")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MtlsCertificatesCredenza
|
||||
{...props}
|
||||
onAdd={onAdd}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type MtlsCertificatesCredenzaProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
certificates: MtlsCertificateListItem[];
|
||||
isLoading: boolean;
|
||||
// resolves true when the certificate was added and the inputs can reset
|
||||
onAdd: (pem: string, name: string | null) => Promise<boolean>;
|
||||
onRemove: (mtlsCertificateId: number) => Promise<void>;
|
||||
};
|
||||
|
||||
export function MtlsCertificatesCredenza({
|
||||
open,
|
||||
onOpenChange,
|
||||
certificates,
|
||||
isLoading,
|
||||
onAdd,
|
||||
onRemove
|
||||
}: MtlsCertificatesCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
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);
|
||||
|
||||
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 {
|
||||
if (await onAdd(pem, name.trim() || null)) {
|
||||
setPem("");
|
||||
setName("");
|
||||
}
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemoveCertificate(mtlsCertificateId: number) {
|
||||
setRemovingId(mtlsCertificateId);
|
||||
try {
|
||||
await onRemove(mtlsCertificateId);
|
||||
} finally {
|
||||
setRemovingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent className="max-w-2xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("mtlsCaCertificates")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("mtlsCaCertificatesDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-6">
|
||||
{isLoading ? null : certificates.length > 0 ? (
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,17 @@ export const createPolicySchema = z.object({
|
||||
})
|
||||
.nullable()
|
||||
.default(null),
|
||||
mtlsEnabled: z.boolean().default(false),
|
||||
// PEM blocks held locally until the policy is created
|
||||
mtlsCertificates: z
|
||||
.array(
|
||||
z.object({
|
||||
mtlsCertificateId: z.number(),
|
||||
name: z.string().nullable(),
|
||||
certificate: z.string()
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
applyRules: z.boolean().default(false),
|
||||
rules: z
|
||||
.array(
|
||||
|
||||
@@ -4,7 +4,8 @@ export type PolicyAuthMethodId =
|
||||
| "pincode"
|
||||
| "passcode"
|
||||
| "email"
|
||||
| "headerAuth";
|
||||
| "headerAuth"
|
||||
| "mtls";
|
||||
|
||||
export const setPasswordSchema = z.object({
|
||||
password: z.string().min(4).max(100)
|
||||
|
||||
@@ -43,3 +43,10 @@ export function getHeaderAuthSummary({
|
||||
}: SummaryParams & { headerName: string }) {
|
||||
return headerName || t("policyAuthHeaderAuthSummary");
|
||||
}
|
||||
|
||||
export function getMtlsSummary({
|
||||
t,
|
||||
count
|
||||
}: SummaryParams & { count: number }) {
|
||||
return t("policyAuthMtlsSummary", { count });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import ResourceContext from "@app/contexts/resourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { resourceModeSupportsMtls } from "@server/lib/mtlsModes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
|
||||
// mTLS is set on the policy being edited, like the other auth methods. When
|
||||
// editing in the context of a resource it doesn't apply to TCP/UDP resources.
|
||||
export function usePolicyMtls() {
|
||||
const { policy, updatePolicy } = useResourcePolicyContext();
|
||||
const resourceContext = useContext(ResourceContext);
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const resource = resourceContext?.resource;
|
||||
|
||||
const supported =
|
||||
!env.flags.disableEnterpriseFeatures &&
|
||||
(!resource || resourceModeSupportsMtls(resource.mode));
|
||||
|
||||
const { data: certificates = [], isLoading: isLoadingCertificates } =
|
||||
useQuery({
|
||||
...orgQueries.resourcePolicyMtlsCertificates({
|
||||
resourcePolicyId: policy.resourcePolicyId
|
||||
}),
|
||||
enabled: supported
|
||||
});
|
||||
|
||||
const [enabled, setEnabled] = useState(policy.mtlsEnabled);
|
||||
|
||||
// The server switches mTLS off when the last CA is removed.
|
||||
useEffect(() => {
|
||||
setEnabled(policy.mtlsEnabled);
|
||||
}, [policy.mtlsEnabled]);
|
||||
|
||||
const hasCertificates = certificates.length > 0;
|
||||
const isDirty = supported && enabled !== policy.mtlsEnabled;
|
||||
|
||||
async function save(): Promise<AxiosResponse | void> {
|
||||
if (!supported || !isDirty) return;
|
||||
const res = await api.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}`,
|
||||
{ mtlsEnabled: enabled }
|
||||
);
|
||||
updatePolicy({ mtlsEnabled: enabled });
|
||||
return res;
|
||||
}
|
||||
|
||||
return {
|
||||
supported,
|
||||
// A policy page has no single resource; there it applies to the
|
||||
// policy's resources that have SSL enabled.
|
||||
sslEnabled: resource ? Boolean(resource.ssl) : true,
|
||||
enabled,
|
||||
setEnabled,
|
||||
certificates,
|
||||
isLoadingCertificates,
|
||||
hasCertificates,
|
||||
isDirty,
|
||||
save
|
||||
};
|
||||
}
|
||||
+25
-13
@@ -55,13 +55,15 @@ import type {
|
||||
ListLauncherSitesResponse,
|
||||
ListLauncherViewsResponse
|
||||
} from "@server/routers/launcher/types";
|
||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||
import type {
|
||||
GetResourcePolicyResponse,
|
||||
ListResourcePolicyMtlsCertificatesResponse
|
||||
} from "@server/routers/policy";
|
||||
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
||||
import type {
|
||||
GetResourcePoliciesResponse,
|
||||
GetResourceWhitelistResponse,
|
||||
ListResourceAiModelsResponse,
|
||||
ListResourceMtlsCertificatesResponse,
|
||||
ListResourceNamesResponse,
|
||||
ListResourceRolesResponse,
|
||||
ListResourceRulesResponse,
|
||||
@@ -927,6 +929,27 @@ export const orgQueries = {
|
||||
|
||||
return res.data.data;
|
||||
}
|
||||
}),
|
||||
resourcePolicyMtlsCertificates: ({
|
||||
resourcePolicyId
|
||||
}: {
|
||||
resourcePolicyId: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
"RESOURCE_POLICY",
|
||||
resourcePolicyId,
|
||||
"MTLS_CERTIFICATES"
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListResourcePolicyMtlsCertificatesResponse>
|
||||
>(`/resource-policy/${resourcePolicyId}/mtls/certificates`, {
|
||||
signal
|
||||
});
|
||||
|
||||
return res.data.data.certificates;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -1657,17 +1680,6 @@ 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