diff --git a/config/traefik/traefik_config.yml b/config/traefik/traefik_config.yml index d1d093976..6d98463e3 100644 --- a/config/traefik/traefik_config.yml +++ b/config/traefik/traefik_config.yml @@ -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 diff --git a/messages/da-DK.json b/messages/da-DK.json index 4071b1e63..b9178ddb9 100644 --- a/messages/da-DK.json +++ b/messages/da-DK.json @@ -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", diff --git a/messages/en-US.json b/messages/en-US.json index 760a42a24..e7d3426dd 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index c3b9d8d9d..bd79fc7f4 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -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; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -760,6 +744,3 @@ export type AlertEmailRecipients = InferSelectModel< export type AlertWebhookActions = InferSelectModel; export type TrialNotification = InferSelectModel; export type AiSessionLog = InferSelectModel; -export type ResourceMtlsCertificate = InferSelectModel< - typeof resourceMtlsCertificates ->; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 31cedeba9..aff7cc70e 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -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; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -2147,3 +2187,9 @@ export type SiteResourceAiProvider = InferSelectModel< export type ResourceAiModel = InferSelectModel; export type SiteResourceAiModel = InferSelectModel; export type Certificate = InferSelectModel; +export type ResourceMtlsCertificate = InferSelectModel< + typeof resourceMtlsCertificates +>; +export type ResourcePolicyMtlsCertificate = InferSelectModel< + typeof resourcePolicyMtlsCertificates +>; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 4f0ae60c9..8db5bf99d 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -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; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -756,6 +735,3 @@ export type AlertEmailRecipient = InferSelectModel; export type AlertWebhookAction = InferSelectModel; export type TrialNotification = InferSelectModel; export type AiSessionLog = InferSelectModel; -export type ResourceMtlsCertificate = InferSelectModel< - typeof resourceMtlsCertificates ->; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index ff2c29046..2cd3494e5 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -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; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -2190,3 +2241,9 @@ export type SiteResourceAiProvider = InferSelectModel< export type ResourceAiModel = InferSelectModel; export type SiteResourceAiModel = InferSelectModel; export type Certificate = InferSelectModel; +export type ResourceMtlsCertificate = InferSelectModel< + typeof resourceMtlsCertificates +>; +export type ResourcePolicyMtlsCertificate = InferSelectModel< + typeof resourcePolicyMtlsCertificates +>; diff --git a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx index dc3890abe..ec9937691 100644 --- a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx +++ b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx @@ -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 } ]} /> diff --git a/server/lib/backupFileName.ts b/server/lib/backupFileName.ts index 9d2c1bd62..69b08dc8b 100644 --- a/server/lib/backupFileName.ts +++ b/server/lib/backupFileName.ts @@ -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"); diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index d4aeb67f2..b4d0fb501 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -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 = { @@ -40,7 +38,6 @@ export const tierMatrix: Record = { [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.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"] }; diff --git a/server/lib/blueprints/exportResourceBlueprint.ts b/server/lib/blueprints/exportResourceBlueprint.ts index ed5b02bd0..7b5d4c68e 100644 --- a/server/lib/blueprints/exportResourceBlueprint.ts +++ b/server/lib/blueprints/exportResourceBlueprint.ts @@ -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 | 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 } diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 6a52a9872..ca4b7c2e8 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -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[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 diff --git a/server/lib/blueprints/resourcePolicies.ts b/server/lib/blueprints/resourcePolicies.ts index babb1c10a..0158b3818 100644 --- a/server/lib/blueprints/resourcePolicies.ts +++ b/server/lib/blueprints/resourcePolicies.ts @@ -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 }); } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 94b9d380b..a61ca6ca4 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -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; // Schema for the entire configuration object diff --git a/server/lib/mtls.ts b/server/lib/mtls.ts new file mode 100644 index 000000000..7e82f80fe --- /dev/null +++ b/server/lib/mtls.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + const rows = await selectCertificates(owner, trx); + return rows.map((r) => r.certificate); +} + +export async function countMtlsCertificates( + owner: MtlsOwner, + trx: DbOrTrx = db +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + // A PEM entry may itself be a bundle; flatten and dedupe by fingerprint. + const desired = new Map(); + 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 { + 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 { + return sql`(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 { + 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); +} diff --git a/server/lib/mtlsModes.ts b/server/lib/mtlsModes.ts new file mode 100644 index 000000000..9067a85c2 --- /dev/null +++ b/server/lib/mtlsModes.ts @@ -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 ?? ""); +} diff --git a/server/lib/offlineThresholds.ts b/server/lib/offlineThresholds.ts index e3c2d369a..ad1738a4e 100644 --- a/server/lib/offlineThresholds.ts +++ b/server/lib/offlineThresholds.ts @@ -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 + }; } diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 0d8786f89..d53045a9f 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -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( diff --git a/server/lib/traefik/browserGateway.ts b/server/lib/traefik/browserGateway.ts index 98a6690a6..4537bd6cc 100644 --- a/server/lib/traefik/browserGateway.ts +++ b/server/lib/traefik/browserGateway.ts @@ -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; + // trusted client-CA PEMs for resources that require mTLS + mtlsCaCertsByResource: Map; 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) { diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 980837cf3..a6e76464a 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -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 = 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 = 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, diff --git a/server/lib/traefik/mtls.ts b/server/lib/traefik/mtls.ts new file mode 100644 index 000000000..e1415f479 --- /dev/null +++ b/server/lib/traefik/mtls.ts @@ -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> { + const byResource = new Map(); + 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(); + 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; +} diff --git a/server/lib/traefik/redirect.ts b/server/lib/traefik/redirect.ts index 05f6eae00..ecaee1102 100644 --- a/server/lib/traefik/redirect.ts +++ b/server/lib/traefik/redirect.ts @@ -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; }): 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, diff --git a/server/lib/traefik/types.ts b/server/lib/traefik/types.ts index cec77aad6..07f3e4d5c 100644 --- a/server/lib/traefik/types.ts +++ b/server/lib/traefik/types.ts @@ -34,7 +34,6 @@ export type ResourceWithTargets = Pick< | "enabled" | "stickySession" | "loadBalancingStrategy" - | "mtlsEnabled" | "tlsServerName" | "setHostHeader" | "enableProxy" diff --git a/server/license/license.ts b/server/license/license.ts index d39842f44..a6e58b5a8 100644 --- a/server/license/license.ts +++ b/server/license/license.ts @@ -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 { diff --git a/server/openApi.ts b/server/openApi.ts index 94e2079d0..3c77ed979 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -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 diff --git a/server/private/lib/dns/server.ts b/server/private/lib/dns/server.ts index 4198f3d2e..c4272d31c 100644 --- a/server/private/lib/dns/server.ts +++ b/server/private/lib/dns/server.ts @@ -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 { + // 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(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 { + const cacheKey = `redirectRecords:${name}:${queryType}`; + + const cached = this.cache.get(cacheKey); + if (cached) { + logger.debug(`Cache hit for ${cacheKey}`); + return cached; + } + + return this.withInFlightDedup(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 { + const normalized = name.replace(/\.$/, ""); + const cacheKey = `redirect:${normalized}`; + + const cached = this.cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + return this.withInFlightDedup(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 { + // 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 diff --git a/server/private/lib/offlineThresholds.ts b/server/private/lib/offlineThresholds.ts deleted file mode 100644 index 2f3d392ac..000000000 --- a/server/private/lib/offlineThresholds.ts +++ /dev/null @@ -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 - }; -} diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index cd24b053b..a78a16d3b 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -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), diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 9e57c9295..77b099c2b 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -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(); - 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, diff --git a/server/private/license/license.ts b/server/private/license/license.ts index 6614e1a6f..00c5b1fcb 100644 --- a/server/private/license/license.ts +++ b/server/private/license/license.ts @@ -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; } diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index b26d6afe5..84ee0eba0 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -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 { }) .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 {} diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index 7cf21577a..3783c0d71 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -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` }), { diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 720183a36..cad76b80d 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -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 -); diff --git a/server/private/routers/generatedLicense/generateNewLicense.ts b/server/private/routers/generatedLicense/generateNewLicense.ts index a97535d8a..a4e09a2f9 100644 --- a/server/private/routers/generatedLicense/generateNewLicense.ts +++ b/server/private/routers/generatedLicense/generateNewLicense.ts @@ -41,6 +41,7 @@ export interface LicenseKey { type: string; quantity: number; quantity_2: number; + quantity_3: number; isValid: boolean; updatedAt: string; createdAt: string; diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 7000f5aa3..47c3c0021 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -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, { diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index c4bbe4160..9d92d3481 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -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 -); diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 8131603dd..23963562f 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -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(); + 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; }); diff --git a/server/private/routers/re-key/index.ts b/server/private/routers/re-key/index.ts deleted file mode 100644 index 922a021fd..000000000 --- a/server/private/routers/re-key/index.ts +++ /dev/null @@ -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"; diff --git a/server/private/routers/remoteExitNode/offlineChecker.ts b/server/private/routers/remoteExitNode/offlineChecker.ts index b916102ea..59cc58dbe 100644 --- a/server/private/routers/remoteExitNode/offlineChecker.ts +++ b/server/private/routers/remoteExitNode/offlineChecker.ts @@ -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; diff --git a/server/private/routers/resource/createResourceMtlsCertificate.ts b/server/private/routers/resource/createResourceMtlsCertificate.ts deleted file mode 100644 index 5ad04b55d..000000000 --- a/server/private/routers/resource/createResourceMtlsCertificate.ts +++ /dev/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 { - 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(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") - ); - } -} diff --git a/server/private/routers/resource/index.ts b/server/private/routers/resource/index.ts index 6cf21f900..ea9b87d65 100644 --- a/server/private/routers/resource/index.ts +++ b/server/private/routers/resource/index.ts @@ -12,6 +12,3 @@ */ export * from "./getMaintenanceInfo"; -export * from "./deleteResourceMtlsCertificate"; -export * from "./createResourceMtlsCertificate"; -export * from "./listResourceMtlsCertificates"; diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index e24e64729..748164dc8 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -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(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( + 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( diff --git a/server/routers/external.ts b/server/routers/external.ts index d5faba7cd..078863e6c 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -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); diff --git a/server/routers/generatedLicense/types.ts b/server/routers/generatedLicense/types.ts index d78f23326..d71dfb69a 100644 --- a/server/routers/generatedLicense/types.ts +++ b/server/routers/generatedLicense/types.ts @@ -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; diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 20b6d9c78..bab8e412e 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -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 +); diff --git a/server/routers/newt/offlineChecker.ts b/server/routers/newt/offlineChecker.ts index 075800747..defa8e829 100644 --- a/server/routers/newt/offlineChecker.ts +++ b/server/routers/newt/offlineChecker.ts @@ -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; diff --git a/server/routers/olm/offlineChecker.ts b/server/routers/olm/offlineChecker.ts index a5558fd24..3b6444faa 100644 --- a/server/routers/olm/offlineChecker.ts +++ b/server/routers/olm/offlineChecker.ts @@ -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; diff --git a/server/routers/policy/createResourcePolicyMtlsCertificate.ts b/server/routers/policy/createResourcePolicyMtlsCertificate.ts new file mode 100644 index 000000000..b2b274c15 --- /dev/null +++ b/server/routers/policy/createResourcePolicyMtlsCertificate.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/routers/policy/deleteResourcePolicyMtlsCertificate.ts b/server/routers/policy/deleteResourcePolicyMtlsCertificate.ts new file mode 100644 index 000000000..dbbc184be --- /dev/null +++ b/server/routers/policy/deleteResourcePolicyMtlsCertificate.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts index fafa31d7d..e2103557c 100644 --- a/server/routers/policy/getResourcePolicy.ts +++ b/server/routers/policy/getResourcePolicy.ts @@ -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, diff --git a/server/routers/policy/index.ts b/server/routers/policy/index.ts index 2ebe6da7e..2549d2a69 100644 --- a/server/routers/policy/index.ts +++ b/server/routers/policy/index.ts @@ -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"; diff --git a/server/routers/policy/listResourcePolicyMtlsCertificates.ts b/server/routers/policy/listResourcePolicyMtlsCertificates.ts new file mode 100644 index 000000000..2cae495d9 --- /dev/null +++ b/server/routers/policy/listResourcePolicyMtlsCertificates.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/routers/policy/updateResourcePolicy.ts b/server/routers/policy/updateResourcePolicy.ts index f42f1bc74..d51d938cd 100644 --- a/server/routers/policy/updateResourcePolicy.ts +++ b/server/routers/policy/updateResourcePolicy.ts @@ -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) diff --git a/server/routers/re-key/index.ts b/server/routers/re-key/index.ts new file mode 100644 index 000000000..beb38a92e --- /dev/null +++ b/server/routers/re-key/index.ts @@ -0,0 +1,3 @@ +export * from "./reGenerateClientSecret"; +export * from "./reGenerateSiteSecret"; +export * from "./reGenerateExitNodeSecret"; diff --git a/server/private/routers/re-key/reGenerateClientSecret.ts b/server/routers/re-key/reGenerateClientSecret.ts similarity index 80% rename from server/private/routers/re-key/reGenerateClientSecret.ts rename to server/routers/re-key/reGenerateClientSecret.ts index c0c160f7e..53217de24 100644 --- a/server/private/routers/re-key/reGenerateClientSecret.ts +++ b/server/routers/re-key/reGenerateClientSecret.ts @@ -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; +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, diff --git a/server/private/routers/re-key/reGenerateExitNodeSecret.ts b/server/routers/re-key/reGenerateExitNodeSecret.ts similarity index 68% rename from server/private/routers/re-key/reGenerateExitNodeSecret.ts rename to server/routers/re-key/reGenerateExitNodeSecret.ts index df5f57048..80bcb166d 100644 --- a/server/private/routers/re-key/reGenerateExitNodeSecret.ts +++ b/server/routers/re-key/reGenerateExitNodeSecret.ts @@ -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( diff --git a/server/private/routers/re-key/reGenerateSiteSecret.ts b/server/routers/re-key/reGenerateSiteSecret.ts similarity index 86% rename from server/private/routers/re-key/reGenerateSiteSecret.ts rename to server/routers/re-key/reGenerateSiteSecret.ts index 2bb29b202..3afbe58a3 100644 --- a/server/private/routers/re-key/reGenerateSiteSecret.ts +++ b/server/routers/re-key/reGenerateSiteSecret.ts @@ -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, diff --git a/server/routers/resource/createResourceMtlsCertificate.ts b/server/routers/resource/createResourceMtlsCertificate.ts new file mode 100644 index 000000000..69d26a461 --- /dev/null +++ b/server/routers/resource/createResourceMtlsCertificate.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/private/routers/resource/deleteResourceMtlsCertificate.ts b/server/routers/resource/deleteResourceMtlsCertificate.ts similarity index 58% rename from server/private/routers/resource/deleteResourceMtlsCertificate.ts rename to server/routers/resource/deleteResourceMtlsCertificate.ts index f9ee3c2f6..758deba62 100644 --- a/server/private/routers/resource/deleteResourceMtlsCertificate.ts +++ b/server/routers/resource/deleteResourceMtlsCertificate.ts @@ -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(res, { data: { mtlsDisabled }, diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index 667870959..07984efa4 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -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, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 7c888004c..c4004fe1b 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -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"; diff --git a/server/routers/resource/inlinePolicyFields.ts b/server/routers/resource/inlinePolicyFields.ts index 9c006beda..5282a8e9a 100644 --- a/server/routers/resource/inlinePolicyFields.ts +++ b/server/routers/resource/inlinePolicyFields.ts @@ -3,7 +3,8 @@ import type { Resource, ResourcePolicy } from "@server/db"; type InlinePolicyFields = Pick< ResourcePolicy, "sso" | "emailWhitelistEnabled" | "applyRules" | "idpId" ->; +> & + Partial>; export function applyInlinePolicyFields( resource: T, @@ -14,6 +15,7 @@ export function applyInlinePolicyFields( 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 }; } diff --git a/server/private/routers/resource/listResourceMtlsCertificates.ts b/server/routers/resource/listResourceMtlsCertificates.ts similarity index 67% rename from server/private/routers/resource/listResourceMtlsCertificates.ts rename to server/routers/resource/listResourceMtlsCertificates.ts index 142d225b7..0df07bbb4 100644 --- a/server/private/routers/resource/listResourceMtlsCertificates.ts +++ b/server/routers/resource/listResourceMtlsCertificates.ts @@ -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(res, { data: { certificates }, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 124c480c2..171aa9250 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -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, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index a366cc7a2..c78b4bdf9 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -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 diff --git a/server/setup/scriptsPg/1.25.0.ts b/server/setup/scriptsPg/1.25.0.ts index 7e7d02e17..14ec3d3a2 100644 --- a/server/setup/scriptsPg/1.25.0.ts +++ b/server/setup/scriptsPg/1.25.0.ts @@ -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`); } diff --git a/server/setup/scriptsSqlite/1.25.0.ts b/server/setup/scriptsSqlite/1.25.0.ts index 79a8cc6c8..f0279179a 100644 --- a/server/setup/scriptsSqlite/1.25.0.ts +++ b/server/setup/scriptsSqlite/1.25.0.ts @@ -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`); } diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx index 2f24b15cf..00cf4e775 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx @@ -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(null); - const [currentRemoteExitNodeId, setCurrentRemoteExitNodeId] = useState< - string | null - >(remoteExitNode.remoteExitNodeId); const [regeneratedSecret, setRegeneratedSecret] = useState( 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 - >(`/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() { - - - - - - {t("endpoint")} - - - - - + + {showEndpoint && ( + + + {t("endpoint")} + + + + + + )} {t("remoteExitNodeId")} @@ -174,6 +152,21 @@ export default function CredentialsPage() { + {displaySecret && ( +
+ +
+ )} + {showCredentialsAlert && displaySecret && ( @@ -196,9 +189,6 @@ export default function CredentialsPage() { setShouldDisconnect(false); setModalOpen(true); }} - disabled={ - !isPaidUser(tierMatrix.rotateCredentials) - } > {t("regenerateCredentialsButton")} @@ -207,9 +197,6 @@ export default function CredentialsPage() { setShouldDisconnect(true); setModalOpen(true); }} - disabled={ - !isPaidUser(tierMatrix.rotateCredentials) - } > {t("remoteExitNodeRegenerateAndDisconnect")} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx index b58403050..cac55907f 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx @@ -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" } ]; diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx index 42ccc4242..dd743339d 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx @@ -186,6 +186,7 @@ export default function NetworkingPage() { rel="noopener noreferrer" className="text-primary hover:underline inline-flex items-center gap-1" > + {" "} {t("learnMore")} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx index 13c62a3e7..00c6add4b 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx @@ -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() {
diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx index 634994b25..c575a53b2 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx @@ -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() { - - @@ -182,9 +173,6 @@ export default function CredentialsPage() { setShouldDisconnect(false); setModalOpen(true); }} - disabled={ - !isPaidUser(tierMatrix.rotateCredentials) - } > {t("regenerateCredentialsButton")} @@ -193,9 +181,6 @@ export default function CredentialsPage() { setShouldDisconnect(true); setModalOpen(true); }} - disabled={ - !isPaidUser(tierMatrix.rotateCredentials) - } > {t("clientRegenerateAndDisconnect")} diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx index 8bb9da553..aeb0cd34f 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx @@ -209,10 +209,6 @@ export default function GeneralPage() { - {showRoles && ( - - )} -
{showRoles && ( - + <> + + + + )} diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx index b4bb18f25..160134ca3 100644 --- a/src/app/[orgId]/settings/redirects/page.tsx +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -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 + >( + `/org/${orgId}/batched-certificates?${certSearchParams.toString()}`, + header + ); + initialCertificates = certRes.data.data; + } catch { + // leave undefined so each row falls back to fetching its own + } + } + return ( <> ); diff --git a/src/app/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx index 6aa1a4726..fa41beca4 100644 --- a/src/app/[orgId]/settings/resources/private/page.tsx +++ b/src/app/[orgId]/settings/resources/private/page.tsx @@ -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]) diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx index fcdd9b25a..0346a98f2 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx @@ -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({ /> + + ( + + + + + + )} + /> + + { - 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(null); - const fileInputRef = useRef(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) { - 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>( - `/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 - >( - `/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>( - `/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 ( - <> - -
- - - - - {t("mtlsCaCertificates")} - - - {t("mtlsCaCertificatesDescription")} - - - - -
- {isLoadingCertificates ? null : hasCertificates ? ( -
    - {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 ( -
  • -
    - -
    -
    - - {cert.name || - cert.subject || - t( - "mtlsCaCertificates" - )} - - {expired && ( - - {t( - "mtlsExpired" - )} - - )} - {expiresSoon && ( - - {t( - "mtlsExpiresSoon" - )} - - )} -
    - {cert.name && - cert.subject && ( -

    - { - cert.subject - } -

    - )} - {cert.issuer && ( -

    - {t( - "mtlsIssuedBy", - { - issuer: cert.issuer - } - )} -

    - )} - {cert.notAfter != - null && ( -

    - {t( - "mtlsExpiresOn", - { - date: new Date( - cert.notAfter - ).toLocaleDateString() - } - )} -

    - )} - {cert.fingerprint && ( -

    - { - cert.fingerprint - } -

    - )} -
    -
    - -
  • - ); - })} -
- ) : ( -

- {t("mtlsNoCertificates")} -

- )} - -
-
- - - setName(e.target.value) - } - /> -
-
- -