diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index e41498264..e10b459e9 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -29,25 +29,6 @@ import { labels } from "./schema"; -export const certificates = pgTable("certificates", { - certId: serial("certId").primaryKey(), - domain: varchar("domain", { length: 255 }).notNull().unique(), - domainId: varchar("domainId").references(() => domains.domainId, { - onDelete: "cascade" - }), - wildcard: boolean("wildcard").default(false), - status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, requested, valid, expired, failed - expiresAt: bigint("expiresAt", { mode: "number" }), - lastRenewalAttempt: bigint("lastRenewalAttempt", { mode: "number" }), - createdAt: bigint("createdAt", { mode: "number" }).notNull(), - updatedAt: bigint("updatedAt", { mode: "number" }).notNull(), - orderId: varchar("orderId", { length: 500 }), - errorMessage: text("errorMessage"), - renewalCount: integer("renewalCount").default(0), - certFile: text("certFile"), - keyFile: text("keyFile") -}); - export const dnsChallenge = pgTable("dnsChallenges", { dnsChallengeId: serial("dnsChallengeId").primaryKey(), domain: varchar("domain", { length: 255 }).notNull(), @@ -633,7 +614,6 @@ export const trialNotifications = pgTable("trialNotifications", { export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; -export type Certificate = InferSelectModel; export type DnsChallenge = InferSelectModel; export type Customer = InferSelectModel; export type Subscription = InferSelectModel; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 432afbbc1..5f0fbf344 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -763,7 +763,7 @@ export const roles = pgTable("roles", { name: varchar("name").notNull(), description: varchar("description"), requireDeviceApproval: boolean("requireDeviceApproval").default(false), - sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("none"), // "none" | "full" | "commands" + sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("full"), // "none" | "full" | "commands" sshSudoCommands: text("sshSudoCommands").default("[]"), sshCreateHomeDir: boolean("sshCreateHomeDir").default(true), sshUnixGroups: text("sshUnixGroups").default("[]") @@ -2017,6 +2017,25 @@ export const aiSessionLog = pgTable( ] ); +export const certificates = pgTable("certificates", { + certId: serial("certId").primaryKey(), + domain: varchar("domain", { length: 255 }).notNull().unique(), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "cascade" + }), + wildcard: boolean("wildcard").default(false), + status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, requested, valid, expired, failed + expiresAt: bigint("expiresAt", { mode: "number" }), + lastRenewalAttempt: bigint("lastRenewalAttempt", { mode: "number" }), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull(), + orderId: varchar("orderId", { length: 500 }), + errorMessage: text("errorMessage"), + renewalCount: integer("renewalCount").default(0), + certFile: text("certFile"), + keyFile: text("keyFile") +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -2117,3 +2136,4 @@ export type SiteResourceAiProvider = InferSelectModel< >; export type ResourceAiModel = InferSelectModel; export type SiteResourceAiModel = InferSelectModel; +export type Certificate = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index f8d2f5f09..da77bfed2 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -23,25 +23,6 @@ import { users } from "./schema"; -export const certificates = sqliteTable("certificates", { - certId: integer("certId").primaryKey({ autoIncrement: true }), - domain: text("domain").notNull().unique(), - domainId: text("domainId").references(() => domains.domainId, { - onDelete: "cascade" - }), - wildcard: integer("wildcard", { mode: "boolean" }).default(false), - status: text("status").notNull().default("pending"), // pending, requested, valid, expired, failed - expiresAt: integer("expiresAt"), - lastRenewalAttempt: integer("lastRenewalAttempt"), - createdAt: integer("createdAt").notNull(), - updatedAt: integer("updatedAt").notNull(), - orderId: text("orderId"), - errorMessage: text("errorMessage"), - renewalCount: integer("renewalCount").default(0), - certFile: text("certFile"), - keyFile: text("keyFile") -}); - export const dnsChallenge = sqliteTable("dnsChallenges", { dnsChallengeId: integer("dnsChallengeId").primaryKey({ autoIncrement: true @@ -628,7 +609,6 @@ export const trialNotifications = sqliteTable("trialNotifications", { export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; -export type Certificate = InferSelectModel; export type DnsChallenge = InferSelectModel; export type Customer = InferSelectModel; export type Subscription = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index c70424152..4d3a7482f 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -995,7 +995,7 @@ export const roles = sqliteTable("roles", { requireDeviceApproval: integer("requireDeviceApproval", { mode: "boolean" }).default(false), - sshSudoMode: text("sshSudoMode").default("none"), // "none" | "full" | "commands" + sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands" sshSudoCommands: text("sshSudoCommands").default("[]"), sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default( true @@ -2013,6 +2013,25 @@ export const aiSessionLog = sqliteTable( ] ); +export const certificates = sqliteTable("certificates", { + certId: integer("certId").primaryKey({ autoIncrement: true }), + domain: text("domain").notNull().unique(), + domainId: text("domainId").references(() => domains.domainId, { + onDelete: "cascade" + }), + wildcard: integer("wildcard", { mode: "boolean" }).default(false), + status: text("status").notNull().default("pending"), // pending, requested, valid, expired, failed + expiresAt: integer("expiresAt"), + lastRenewalAttempt: integer("lastRenewalAttempt"), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull(), + orderId: text("orderId"), + errorMessage: text("errorMessage"), + renewalCount: integer("renewalCount").default(0), + certFile: text("certFile"), + keyFile: text("keyFile") +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -2111,3 +2130,4 @@ export type SiteResourceAiProvider = InferSelectModel< >; export type ResourceAiModel = InferSelectModel; export type SiteResourceAiModel = InferSelectModel; +export type Certificate = InferSelectModel; diff --git a/server/index.ts b/server/index.ts index c7b0a5b6e..27c945dc4 100644 --- a/server/index.ts +++ b/server/index.ts @@ -22,14 +22,11 @@ import { } from "@server/db"; import config from "@server/lib/config"; import { setHostMeta } from "@server/lib/hostMeta"; -import { initTelemetryClient } from "@server/lib/telemetry"; import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager"; import { initCleanup } from "#dynamic/cleanup"; +import { startSchedulers } from "#dynamic/startSchedulers"; import license from "#dynamic/license/license"; -import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; -import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync"; import { fetchServerIp } from "@server/lib/serverIpService"; -import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations"; import { initAiModelCatalog } from "@server/lib/aiModelCatalog"; async function startServers() { @@ -44,13 +41,10 @@ async function startServers() { await fetchServerIp(); - initTelemetryClient(); - - initLogCleanupInterval(); - initAcmeCertSync(); - startRebuildQueueProcessor(); await initAiModelCatalog(); + startSchedulers(); + // Start all servers const apiServer = createApiServer(); const internalServer = createInternalServer(); diff --git a/server/lib/acmeCertSync.ts b/server/lib/acmeCertSync.ts index d8fbd6368..12b0c91fc 100644 --- a/server/lib/acmeCertSync.ts +++ b/server/lib/acmeCertSync.ts @@ -1,3 +1,866 @@ +import fs from "fs"; +import path from "path"; +import crypto from "crypto"; +import { + certificates, + clients, + clientSiteResourcesAssociationsCache, + db, + domains, + newts, + siteNetworks, + SiteResource, + siteResources +} from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { encrypt, decrypt } from "@server/lib/crypto"; +import logger from "@server/logger"; +import config from "@server/lib/config"; +import { + generateSubnetProxyTargetV2, + SubnetProxyTargetV2 +} from "@server/lib/ip"; +import { updateTargets } from "@server/routers/client/targets"; +import cache from "#dynamic/lib/cache"; +import { build } from "@server/build"; + +interface AcmeCert { + domain: { main: string; sans?: string[] }; + certificate: string; + key: string; + Store: string; +} + +interface AcmeJson { + [resolver: string]: { + Certificates: AcmeCert[]; + }; +} + +export async function pushCertUpdateToAffectedNewts( + domain: string, + domainId: string | null, + oldCertPem: string | null, + oldKeyPem: string | null +): Promise { + // Find all SSL-enabled HTTP site resources that use this cert's domain + let affectedResources: SiteResource[] = []; + + if (domainId) { + affectedResources = await db + .select() + .from(siteResources) + .where( + and( + eq(siteResources.domainId, domainId), + eq(siteResources.ssl, true) + ) + ); + } else { + // Fallback: match by exact fullDomain when no domainId is available + affectedResources = await db + .select() + .from(siteResources) + .where( + and( + eq(siteResources.fullDomain, domain), + eq(siteResources.ssl, true) + ) + ); + } + + if (affectedResources.length === 0) { + logger.debug( + `acmeCertSync: no affected site resources for cert domain "${domain}"` + ); + return; + } + + logger.debug( + `acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"` + ); + + for (const resource of affectedResources) { + try { + // Get all sites for this resource via siteNetworks + const resourceSiteRows = resource.networkId + ? await db + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where(eq(siteNetworks.networkId, resource.networkId)) + : []; + + if (resourceSiteRows.length === 0) { + logger.debug( + `acmeCertSync: no sites for resource ${resource.siteResourceId}, skipping` + ); + continue; + } + + // Get all clients with access to this resource + const resourceClients = await db + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clients.clientId, + clientSiteResourcesAssociationsCache.clientId + ) + ) + .where( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + resource.siteResourceId + ) + ); + + if (resourceClients.length === 0) { + logger.debug( + `acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping` + ); + continue; + } + + // Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data + if (resource.fullDomain) { + await cache.del(`cert:${resource.fullDomain}`); + } + + // Generate target once - same cert applies to all sites for this resource + const newTargets = await generateSubnetProxyTargetV2( + resource, + resourceClients + ); + + if (!newTargets) { + logger.debug( + `acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping` + ); + continue; + } + + // Construct the old targets - same routing shape but with the previous cert/key. + // The newt only uses destPrefix/sourcePrefixes for removal, but we keep the + // semantics correct so the update message accurately reflects what changed. + const oldTargets: SubnetProxyTargetV2[] = newTargets.map((t) => ({ + ...t, + tlsCert: oldCertPem ?? undefined, + tlsKey: oldKeyPem ?? undefined + })); + + // Push update to each site's newt + for (const { siteId } of resourceSiteRows) { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + + if (!newt) { + logger.debug( + `acmeCertSync: no newt found for site ${siteId}, skipping resource ${resource.siteResourceId}` + ); + continue; + } + + await updateTargets( + newt.newtId, + { oldTargets: oldTargets, newTargets: newTargets }, + newt.version + ); + + logger.debug( + `acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}` + ); + } + } catch (err) { + logger.error( + `acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}` + ); + } + } +} + +async function findDomainId(certDomain: string): Promise { + // Strip wildcard prefix before lookup (*.example.com -> example.com) + const lookupDomain = certDomain.startsWith("*.") + ? certDomain.slice(2) + : certDomain; + + // 1. Exact baseDomain match (any domain type) + const exactMatch = await db + .select({ domainId: domains.domainId }) + .from(domains) + .where(eq(domains.baseDomain, lookupDomain)) + .limit(1); + + if (exactMatch.length > 0) { + return exactMatch[0].domainId; + } + + // 2. Walk up the domain hierarchy looking for a wildcard-type domain whose + // baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com" + // matches a wildcard domain with baseDomain "example.com". + const parts = lookupDomain.split("."); + for (let i = 1; i < parts.length; i++) { + const candidate = parts.slice(i).join("."); + if (!candidate) continue; + + const wildcardMatch = await db + .select({ domainId: domains.domainId }) + .from(domains) + .where( + and( + eq(domains.baseDomain, candidate), + eq(domains.type, "wildcard") + ) + ) + .limit(1); + + if (wildcardMatch.length > 0) { + return wildcardMatch[0].domainId; + } + } + + return null; +} + +function extractFirstCert(pemBundle: string): string | null { + const match = pemBundle.match( + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/ + ); + return match ? match[0] : null; +} + +/** + * Determine whether an ACME cert entry represents a wildcard cert by checking + * both the primary domain (`main`) and the SANs. Some ACME clients (notably + * Traefik) store the bare apex in `main` and only put the wildcard form in + * `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]). + */ +function detectWildcard( + main: string, + sans: string[] | undefined +): { wildcard: boolean; wildcardSan: string | null } { + if (main.startsWith("*.")) { + return { wildcard: true, wildcardSan: null }; + } + if (Array.isArray(sans)) { + for (const san of sans) { + if (typeof san !== "string") continue; + if (san === `*.${main}` || san.startsWith("*.")) { + return { wildcard: true, wildcardSan: san }; + } + } + } + return { wildcard: false, wildcardSan: null }; +} + +interface HttpCert { + wildcard: boolean; + altName: string; + certName: string; + commonName: string; + certFile: string; + keyFile: string; +} + +async function syncAcmeCertsFromHttp(endpoint: string): Promise { + let response: Response; + try { + response = await fetch(endpoint); + } catch (err) { + logger.debug( + `acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}` + ); + return; + } + + if (!response.ok) { + logger.debug( + `acmeCertSync: HTTP endpoint returned status ${response.status}` + ); + return; + } + + let httpCerts: HttpCert[]; + try { + httpCerts = await response.json(); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse JSON from HTTP endpoint: ${err}` + ); + return; + } + + if (!Array.isArray(httpCerts) || httpCerts.length === 0) { + logger.debug( + `acmeCertSync: no certificates returned from HTTP endpoint` + ); + return; + } + + for (const cert of httpCerts) { + const domain = cert?.certName; + + if (!domain || typeof domain !== "string") { + logger.debug( + `acmeCertSync: skipping HTTP cert with missing certName` + ); + continue; + } + + const certPem = cert.certFile; + const keyPem = cert.keyFile; + + if (!certPem?.trim() || !keyPem?.trim()) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile` + ); + continue; + } + + const firstCertPemForValidation = extractFirstCert(certPem); + if (!firstCertPemForValidation) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found` + ); + continue; + } + + let validatedX509: crypto.X509Certificate; + try { + validatedX509 = new crypto.X509Certificate( + firstCertPemForValidation + ); + } catch (err) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}` + ); + continue; + } + + try { + crypto.createPrivateKey(keyPem); + } catch (err) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}` + ); + continue; + } + + const wildcard = cert.wildcard ?? false; + + const existing = await db + .select() + .from(certificates) + .where(eq(certificates.domain, domain)) + .limit(1); + + let oldCertPem: string | null = null; + let oldKeyPem: string | null = null; + + if (existing.length > 0 && existing[0].certFile) { + try { + const storedCertPem = decrypt( + existing[0].certFile, + config.getRawConfig().server.secret! + ); + const wildcardUnchanged = existing[0].wildcard === wildcard; + if (storedCertPem === certPem && wildcardUnchanged) { + continue; + } + oldCertPem = storedCertPem; + if (existing[0].keyFile) { + try { + oldKeyPem = decrypt( + existing[0].keyFile, + config.getRawConfig().server.secret! + ); + } catch (keyErr) { + logger.debug( + `acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}` + ); + } + } + } catch (err) { + logger.debug( + `acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}` + ); + } + } + + let expiresAt: number | null = null; + try { + expiresAt = Math.floor( + new Date(validatedX509.validTo).getTime() / 1000 + ); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` + ); + } + + const encryptedCert = encrypt( + certPem, + config.getRawConfig().server.secret! + ); + const encryptedKey = encrypt( + keyPem, + config.getRawConfig().server.secret! + ); + const now = Math.floor(Date.now() / 1000); + + const domainId = await findDomainId(domain); + if (domainId) { + logger.debug( + `acmeCertSync: resolved domainId "${domainId}" for HTTP cert domain "${domain}"` + ); + } else { + logger.debug( + `acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"` + ); + } + + if (existing.length > 0) { + logger.debug( + `acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db + .update(certificates) + .set({ + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + updatedAt: now, + wildcard, + ...(domainId !== null && { domainId }) + }) + .where(eq(certificates.domain, domain)); + + await pushCertUpdateToAffectedNewts( + domain, + domainId, + oldCertPem, + oldKeyPem + ); + } else { + logger.debug( + `acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db.insert(certificates).values({ + domain, + domainId, + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + createdAt: now, + updatedAt: now, + wildcard + }); + + await pushCertUpdateToAffectedNewts(domain, domainId, null, null); + } + } +} + +async function storeCertForDomain( + domain: string, + certPem: string, + keyPem: string, + validatedX509: crypto.X509Certificate +): Promise { + const wildcard = domain.startsWith("*."); + + const existing = await db + .select() + .from(certificates) + .where(eq(certificates.domain, domain)) + .limit(1); + + let oldCertPem: string | null = null; + let oldKeyPem: string | null = null; + + if (existing.length > 0 && existing[0].certFile) { + try { + const storedCertPem = decrypt( + existing[0].certFile, + config.getRawConfig().server.secret! + ); + const wildcardUnchanged = existing[0].wildcard === wildcard; + if (storedCertPem === certPem && wildcardUnchanged) { + return; + } + oldCertPem = storedCertPem; + if (existing[0].keyFile) { + try { + oldKeyPem = decrypt( + existing[0].keyFile, + config.getRawConfig().server.secret! + ); + } catch (keyErr) { + logger.debug( + `acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}` + ); + } + } + } catch (err) { + logger.debug( + `acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}` + ); + } + } + + let expiresAt: number | null = null; + try { + expiresAt = Math.floor( + new Date(validatedX509.validTo).getTime() / 1000 + ); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` + ); + } + + const encryptedCert = encrypt( + certPem, + config.getRawConfig().server.secret! + ); + const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!); + const now = Math.floor(Date.now() / 1000); + + const domainId = await findDomainId(domain); + if (domainId) { + logger.debug( + `acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"` + ); + } else { + logger.debug( + `acmeCertSync: no matching domain record found for cert domain "${domain}"` + ); + } + + if (existing.length > 0) { + logger.debug( + `acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db + .update(certificates) + .set({ + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + updatedAt: now, + wildcard, + ...(domainId !== null && { domainId }) + }) + .where(eq(certificates.domain, domain)); + + logger.debug( + `acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + + await pushCertUpdateToAffectedNewts( + domain, + domainId, + oldCertPem, + oldKeyPem + ); + } else { + logger.debug( + `acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db.insert(certificates).values({ + domain, + domainId, + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + createdAt: now, + updatedAt: now, + wildcard + }); + + logger.debug( + `acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + + await pushCertUpdateToAffectedNewts(domain, domainId, null, null); + } +} + +function findAcmeJsonFiles(dirPath: string): string[] { + const results: string[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch (err) { + logger.warn( + `acmeCertSync: could not read directory "${dirPath}": ${err}` + ); + return results; + } + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + results.push(...findAcmeJsonFiles(fullPath)); + } else if (entry.isFile()) { + // check if it is a json file + if (entry.name.endsWith(".json")) { + let raw: string; + try { + raw = fs.readFileSync(fullPath, "utf8"); + } catch (err) { + logger.warn( + `acmeCertSync: could not read file "${fullPath}": ${err}` + ); + continue; + } + + let parsed: any; + try { + parsed = JSON.parse(raw); + } catch (err) { + logger.warn( + `acmeCertSync: could not parse "${fullPath}" as JSON: ${err}` + ); + continue; + } + } + + results.push(fullPath); + } + } + return results; +} + +async function syncAcmeCerts(acmeJsonPath: string): Promise { + let raw: string; + try { + raw = fs.readFileSync(acmeJsonPath, "utf8"); + } catch (err) { + logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`); + return; + } + + let acmeJson: AcmeJson; + try { + acmeJson = JSON.parse(raw); + } catch (err) { + logger.warn( + `acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}` + ); + return; + } + + const resolvers = Object.keys(acmeJson || {}); + if (resolvers.length === 0) { + logger.debug(`acmeCertSync: no resolvers found in acme.json`); + return; + } + + // Collect certificates from every resolver. If the same domain appears in + // multiple resolvers, the last one wins (resolvers iterated in object order). + const allCerts: AcmeCert[] = []; + for (const resolver of resolvers) { + const resolverData = acmeJson[resolver]; + if (!resolverData || !Array.isArray(resolverData.Certificates)) { + logger.debug( + `acmeCertSync: no certificates found for resolver "${resolver}"` + ); + continue; + } + // logger.debug( + // `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"` + // ); + for (const cert of resolverData.Certificates) { + allCerts.push(cert); + } + } + + for (const cert of allCerts) { + const mainDomain = cert?.domain?.main; + + if (!mainDomain || typeof mainDomain !== "string") { + logger.debug(`acmeCertSync: skipping cert with missing domain`); + continue; + } + + if (!cert.certificate || !cert.key) { + logger.debug( + `acmeCertSync: skipping cert for ${mainDomain} - empty certificate or key field` + ); + continue; + } + + let certPem: string; + let keyPem: string; + try { + certPem = Buffer.from(cert.certificate, "base64").toString("utf8"); + keyPem = Buffer.from(cert.key, "base64").toString("utf8"); + } catch (err) { + logger.debug( + `acmeCertSync: skipping cert for ${mainDomain} - failed to base64-decode cert/key: ${err}` + ); + continue; + } + + if (!certPem.trim() || !keyPem.trim()) { + logger.debug( + `acmeCertSync: skipping cert for ${mainDomain} - blank PEM after base64 decode` + ); + continue; + } + + // Validate that the decoded data actually parses as a real X.509 cert + // before we touch the database. This prevents importing partially-written + // or corrupted entries from acme.json. + const firstCertPemForValidation = extractFirstCert(certPem); + if (!firstCertPemForValidation) { + logger.debug( + `acmeCertSync: skipping cert for ${mainDomain} - no PEM certificate block found` + ); + continue; + } + + let validatedX509: crypto.X509Certificate; + try { + validatedX509 = new crypto.X509Certificate( + firstCertPemForValidation + ); + } catch (err) { + logger.debug( + `acmeCertSync: skipping cert for ${mainDomain} - invalid X.509 certificate: ${err}` + ); + continue; + } + + // Sanity-check the private key parses too + try { + crypto.createPrivateKey(keyPem); + } catch (err) { + logger.debug( + `acmeCertSync: skipping cert for ${mainDomain} - invalid private key: ${err}` + ); + continue; + } + + // Collect all domains covered by this cert: main + every SAN. + // Each domain gets its own row in the certificates table so that + // lookups by any hostname on the cert succeed independently. + const allDomains = new Set([mainDomain]); + if (Array.isArray(cert.domain?.sans)) { + for (const san of cert.domain.sans) { + if (typeof san === "string" && san.trim()) { + allDomains.add(san.trim()); + } + } + } + + // logger.debug( + // `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}` + // ); + + for (const domain of allDomains) { + try { + await storeCertForDomain( + domain, + certPem, + keyPem, + validatedX509 + ); + } catch (err) { + logger.error( + `acmeCertSync: error storing cert for domain "${domain}": ${err}` + ); + } + } + } +} + export function initAcmeCertSync(): void { - // stub -} \ No newline at end of file + if (build == "saas") { + logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`); + return; + } + + const configData = config.getRawConfig(); + + if (!configData.flags?.enable_acme_cert_sync) { + logger.debug( + `acmeCertSync: ACME cert sync is disabled by config flag, skipping` + ); + return; + } + + const acmeJsonPath = + configData.acme?.acme_json_path ?? "config/letsencrypt/acme.json"; + const intervalMs = configData.acme?.sync_interval_ms ?? 5000; + const httpEndpoint = configData.acme?.acme_http_endpoint; + + logger.debug( + `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms` + ); + if (httpEndpoint) { + logger.debug( + `acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms` + ); + } + + const runSync = () => { + if (httpEndpoint) { + syncAcmeCertsFromHttp(httpEndpoint).catch((err) => { + logger.error(`acmeCertSync: error during HTTP sync: ${err}`); + }); + } else { + // only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up + let stat: fs.Stats | null = null; + try { + stat = fs.statSync(acmeJsonPath); + } catch (err) { + logger.warn( + `acmeCertSync: cannot stat path "${acmeJsonPath}": ${err}` + ); + return; + } + + if (stat.isDirectory()) { + const files = findAcmeJsonFiles(acmeJsonPath); + if (files.length === 0) { + logger.debug( + `acmeCertSync: no acme.json files found in directory "${acmeJsonPath}"` + ); + return; + } + // logger.debug( + // `acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"` + // ); + for (const file of files) { + syncAcmeCerts(file).catch((err) => { + logger.error( + `acmeCertSync: error during sync of "${file}": ${err}` + ); + }); + } + } else { + syncAcmeCerts(acmeJsonPath).catch((err) => { + logger.error(`acmeCertSync: error during sync: ${err}`); + }); + } + } + }; + + // Run immediately on init, then on the configured interval + runSync(); + + setInterval(runSync, intervalMs); +} diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 7c0b591ca..7e49121dc 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -10,7 +10,7 @@ export enum TierFeature { ActionLogs = "actionLogs", // set the retention period to none on downgrade ConnectionLogs = "connectionLogs", RotateCredentials = "rotateCredentials", - MaintencePage = "maintencePage", // handle downgrade + MaintenancePage = "maintenancePage", // handle downgrade DevicePosture = "devicePosture", TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration @@ -25,8 +25,7 @@ export enum TierFeature { WildcardSubdomain = "wildcardSubdomain", NewtAutoUpdate = "newtAutoUpdate", ResourcePolicies = "resourcePolicies", - AdvancedPublicResources = "advancedPublicResources", - AdvancedPrivateResources = "advancedPrivateResources" + RoleBasedSSHControls = "roleBasedSSHControls" } export const tierMatrix: Record = { @@ -39,7 +38,7 @@ export const tierMatrix: Record = { [TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"], [TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"], [TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"], - [TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"], + [TierFeature.MaintenancePage]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"], [TierFeature.TwoFactorEnforcement]: [ "tier1", @@ -69,6 +68,5 @@ export const tierMatrix: Record = { [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.ResourcePolicies]: ["tier3", "enterprise"], - [TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"], - [TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"] + [TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"] }; diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 9cec7b487..65cf199d6 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -23,9 +23,7 @@ import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels"; import logger from "@server/logger"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { getNextAvailableAliasAddress } from "../ip"; -import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; -import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { tierMatrix } from "../billing/tierMatrix"; +import { createCertificate } from "@server/routers/certificates/createCertificate"; import { build } from "@server/build"; import { LimitId } from "../billing"; import { usageService } from "../billing/usageService"; @@ -128,30 +126,6 @@ export async function updatePrivateResources( for (const [resourceNiceId, resourceData] of Object.entries( config["client-resources"] )) { - if (resourceData.mode === "http") { - const hasHttpFeature = await isLicensedOrSubscribed( - orgId, - tierMatrix.advancedPrivateResources - ); - if (!hasHttpFeature) { - throw new Error( - "HTTP private resources are not included in your current plan. Please upgrade." - ); - } - } - - if (resourceData.mode === "ssh") { - const hasSshFeature = await isLicensedOrSubscribed( - orgId, - tierMatrix.advancedPrivateResources - ); - if (!hasSshFeature) { - throw new Error( - "SSH private resources are not included in your current plan. Please upgrade." - ); - } - } - const [existingResource] = await trx .select() .from(siteResources) diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 2f4d9773d..a76bcc26c 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -1,5 +1,5 @@ import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { createCertificate } from "@server/routers/certificates/createCertificate"; import { hashPassword } from "@server/auth/password"; import { generateId } from "@server/auth/sessions/app"; import { build } from "@server/build"; @@ -51,9 +51,6 @@ import { tierMatrix } from "../billing/tierMatrix"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { Config, isTargetsOnlyResource, TargetData } from "./types"; import { getOrCreateLabelIds, syncResourceLabels } from "./labels"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import next from "next"; import { LimitId } from "../billing"; import { usageService } from "../billing/usageService"; import { syncInferenceAiConfig } from "./aiProviders"; @@ -262,18 +259,6 @@ export async function updatePublicResources( headers = JSON.stringify(resourceData.headers); } - if (["ssh", "rdp", "vnc"].includes(resourceData.mode || "")) { - const isLicensed = await isLicensedOrSubscribed( - orgId, - tierMatrix.advancedPublicResources - ); - if (!isLicensed) { - throw new Error( - "Your current subscription does not support browser gateway resources. Please upgrade to access this feature." - ); - } - } - if (resourceData.policy) { const isLicensed = await isLicensedOrSubscribed( orgId, @@ -331,7 +316,7 @@ export async function updatePublicResources( const isLicensed = await isLicensedOrSubscribed( orgId, - tierMatrix.maintencePage + tierMatrix.maintenancePage ); if (!isLicensed) { resourceData.maintenance = undefined; @@ -1138,7 +1123,7 @@ export async function updatePublicResources( const isLicensed = await isLicensedOrSubscribed( orgId, - tierMatrix.maintencePage + tierMatrix.maintenancePage ); if (!isLicensed) { resourceData.maintenance = undefined; diff --git a/server/lib/certificates.ts b/server/lib/certificates.ts index 6d24d2996..53d826586 100644 --- a/server/lib/certificates.ts +++ b/server/lib/certificates.ts @@ -1,17 +1,226 @@ +import config from "@server/lib/config"; +import { certificates, db } from "@server/db"; +import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; +import { decrypt } from "@server/lib/crypto"; +import logger from "@server/logger"; +import { regionalCache as cache } from "#dynamic/lib/cache"; +import { build } from "@server/build"; + +// Define the return type for clarity and type safety +export type CertificateResult = { + id: number; + domain: string; + queriedDomain: string; // The domain that was originally requested (may differ for wildcards) + wildcard: boolean | null; + certFile: string | null; + keyFile: string | null; + expiresAt: number | null; + updatedAt?: number | null; +}; + export async function getValidCertificatesForDomains( domains: Set, useCache: boolean = true -): Promise< - Array<{ - id: number; - domain: string; - queriedDomain: string; - wildcard: boolean | null; - certFile: string | null; - keyFile: string | null; - expiresAt: number | null; - updatedAt?: number | null; - }> -> { - return []; // stub +): Promise> { + const finalResults: CertificateResult[] = []; + const domainsToQuery = new Set(); + + // 1. Check cache first if enabled + if (useCache) { + for (const domain of domains) { + const cacheKey = `cert:${domain}`; + const cachedCert = await cache.get(cacheKey); + if (cachedCert) { + finalResults.push(cachedCert); // Valid cache hit + } else { + // Also check for a wildcard cache entry covering this domain's parent + const parts = domain.split("."); + let wildcardHit = false; + if (parts.length > 1) { + const parentDomain = parts.slice(1).join("."); + const wildcardCacheKey = `cert:*.${parentDomain}`; + const cachedWildcard = + await cache.get(wildcardCacheKey); + if (cachedWildcard) { + // Re-stamp queriedDomain so callers see the originally requested domain + finalResults.push({ + ...cachedWildcard, + queriedDomain: domain + }); + wildcardHit = true; + } + } + if (!wildcardHit) { + domainsToQuery.add(domain); // Cache miss or expired + } + } + } + } else { + // If caching is disabled, add all domains to the query set + domains.forEach((d) => domainsToQuery.add(d)); + } + + // 2. If all domains were resolved from the cache, return early + if (domainsToQuery.size === 0) { + const decryptedResults = decryptFinalResults( + finalResults, + config.getRawConfig().server.secret! + ); + return decryptedResults; + } + + // 3. Prepare domains for the database query + const domainsToQueryArray = Array.from(domainsToQuery); + const parentDomainsToQuery = new Set(); + + domainsToQueryArray.forEach((domain) => { + const parts = domain.split("."); + // A wildcard can only match a domain with at least two parts (e.g., example.com) + if (parts.length > 1) { + parentDomainsToQuery.add(parts.slice(1).join(".")); + } + }); + + const parentDomainsArray = Array.from(parentDomainsToQuery); + + // Build wildcard variants: for each parent domain "example.com", also query "*.example.com" + const wildcardPrefixedArray = + build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : []; + + // 4. Build and execute a single, efficient Drizzle query + // This query fetches all potential exact and wildcard matches in one database round-trip. + const potentialCerts = await db + .select() + .from(certificates) + .where( + and( + eq(certificates.status, "valid"), + isNotNull(certificates.certFile), + isNotNull(certificates.keyFile), + or( + // Condition for exact matches on the requested domains + inArray(certificates.domain, domainsToQueryArray), + // Condition for wildcard matches on the parent domains (stored as "example.com" or "*.example.com") + parentDomainsArray.length > 0 + ? and( + inArray(certificates.domain, [ + ...parentDomainsArray, + ...wildcardPrefixedArray + ]), + eq(certificates.wildcard, true) + ) + : // If there are no possible parent domains, this condition is false + sql`false` + ) + ) + ); + + // Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.") + const normalizeWildcardDomain = (domain: string): string => + domain.startsWith("*.") ? domain.slice(2) : domain; + + // 5. Process the database results, prioritizing exact matches over wildcards + const exactMatches = new Map(); + const wildcardMatches = new Map(); + + for (const cert of potentialCerts) { + if (cert.wildcard) { + // Normalize to bare parent domain so lookups are consistent regardless of storage format + wildcardMatches.set(normalizeWildcardDomain(cert.domain), cert); + } else { + exactMatches.set(cert.domain, cert); + } + } + + for (const domain of domainsToQuery) { + let foundCert: (typeof potentialCerts)[0] | undefined = undefined; + + // Priority 1: Check for an exact match (non-wildcard) + if (exactMatches.has(domain)) { + foundCert = exactMatches.get(domain); + } + // Priority 2: Check for a wildcard certificate whose normalized domain equals the queried domain + else { + const normalizedDomain = normalizeWildcardDomain(domain); + if (wildcardMatches.has(normalizedDomain)) { + foundCert = wildcardMatches.get(normalizedDomain); + } + // Priority 3: Check for a wildcard match on the parent domain + else { + const parts = normalizedDomain.split("."); + if (parts.length > 1) { + const parentDomain = parts.slice(1).join("."); + if (wildcardMatches.has(parentDomain)) { + foundCert = wildcardMatches.get(parentDomain); + } + } + } + } + + // If a certificate was found, format it, add to results, and cache it + if (foundCert) { + logger.debug( + `Creating result cert for ${domain} using cert from ${foundCert.domain}` + ); + const resultCert: CertificateResult = { + id: foundCert.certId, + domain: foundCert.domain, // The actual domain of the cert record + queriedDomain: domain, // The domain that was originally requested + wildcard: foundCert.wildcard, + certFile: foundCert.certFile, + keyFile: foundCert.keyFile, + expiresAt: foundCert.expiresAt, + updatedAt: foundCert.updatedAt + }; + + finalResults.push(resultCert); + + // Add to cache for future requests, using the *requested domain* as the key + if (useCache) { + const cacheKey = `cert:${domain}`; + await cache.set(cacheKey, resultCert, 180); + + // Also cache wildcard certs under a pattern key so other subdomains + // can find them without a DB round-trip + if (resultCert.wildcard) { + const normalizedCertDomain = normalizeWildcardDomain( + resultCert.domain + ); + const wildcardCacheKey = `cert:*.${normalizedCertDomain}`; + await cache.set(wildcardCacheKey, resultCert, 180); + } + } + } + } + + const decryptedResults = decryptFinalResults( + finalResults, + config.getRawConfig().server.secret! + ); + return decryptedResults; +} + +function decryptFinalResults( + finalResults: CertificateResult[], + secret: string +): CertificateResult[] { + const validCertsDecrypted = finalResults.map((cert) => { + // Decrypt and save certificate file + const decryptedCert = decrypt( + cert.certFile!, // is not null from query + secret + ); + + // Decrypt and save key file + const decryptedKey = decrypt(cert.keyFile!, secret); + + // Return only the certificate data without org information + return { + ...cert, + certFile: decryptedCert, + keyFile: decryptedKey + }; + }); + + return validCertsDecrypted; } diff --git a/server/lib/ip.ts b/server/lib/ip.ts index bb21d28d6..518bb36db 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -6,7 +6,7 @@ import z from "zod"; import logger from "@server/logger"; import semver from "semver"; import { createHash } from "crypto"; -import { getValidCertificatesForDomains } from "#dynamic/lib/certificates"; +import { getValidCertificatesForDomains } from "@server/lib/certificates"; import { lockManager } from "#dynamic/lib/lock"; interface IPRange { diff --git a/server/lib/logAccessAudit.ts b/server/lib/logAccessAudit.ts index 5f3601da0..bfff44441 100644 --- a/server/lib/logAccessAudit.ts +++ b/server/lib/logAccessAudit.ts @@ -7,6 +7,7 @@ export async function logAccessAudit(data: { type: string; orgId: string; resourceId?: number; + siteResourceId?: number; user?: { username: string; userId: string }; apiKey?: { name: string | null; apiKeyId: string }; metadata?: any; diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index adb60ef4a..5f6939675 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -167,9 +167,8 @@ export const configSchema = z .transform((val) => process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER !== undefined - ? process.env - .ENABLE_AI_GATEWAY_CLIENT_IP_HEADER === - "true" + ? process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER === + "true" : val ), secret: z.string().pipe(z.string().min(8)).optional(), @@ -443,7 +442,18 @@ export const configSchema = z disable_basic_wireguard_sites: z.boolean().optional(), disable_config_managed_domains: z.boolean().optional(), disable_product_help_banners: z.boolean().optional(), - disable_enterprise_features: z.boolean().optional() + disable_enterprise_features: z.boolean().optional(), + enable_acme_cert_sync: z.boolean().optional().default(true) + }) + .optional(), + acme: z + .object({ + acme_json_path: z + .string() + .optional() + .default("config/letsencrypt/acme.json"), + acme_http_endpoint: z.string().optional(), + sync_interval_ms: z.number().optional().default(5000) }) .optional(), ai: z diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index 91bc37249..bc1bac221 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -8,7 +8,7 @@ import { db, exitNodes } from "@server/db"; import { eq } from "drizzle-orm"; import { getCurrentExitNodeId } from "@server/lib/exitNodes"; import { getTraefikConfig } from "#dynamic/lib/traefik"; -import { getValidCertificatesForDomains } from "#dynamic/lib/certificates"; +import { getValidCertificatesForDomains } from "@server/lib/certificates"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; @@ -628,8 +628,7 @@ export class TraefikConfigManager { .name, remoteRoleHeader: - config.getRawConfig().server.remote_headers - .role + config.getRawConfig().server.remote_headers.role } } }; diff --git a/server/lib/traefik/aiGatewayMiddlewares.ts b/server/lib/traefik/aiGatewayMiddlewares.ts new file mode 100644 index 000000000..5baa82519 --- /dev/null +++ b/server/lib/traefik/aiGatewayMiddlewares.ts @@ -0,0 +1,165 @@ +import config from "@server/lib/config"; +import { + AI_GATEWAY_TRUST_HEADER, + AI_GATEWAY_RESOURCE_TYPE_HEADER, + AI_GATEWAY_CLIENT_IP_HEADER, + getAiGatewayTrustToken +} from "@server/lib/aiGatewayTrust"; + +// The trust token is the same for every inference route on an exit node, so +// these middlewares are built once and attached to each inference router. +// Two variants exist (public resource vs. siteResource) so the resource +// type header lets the gateway know which kind of router the request came +// through without re-deriving it from resourceId. +export const AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE = + "ai-gateway-trust-headers-resource"; +export const AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE = + "ai-gateway-trust-headers-site-resource"; + +// Opt-in: a Badger instance with forward auth disabled, used only to stamp +// the resolved client IP into a dedicated header before the request reaches +// whatever sits between Traefik and the AI gateway. Only the site-resource +// router needs this - it's the only path that resolves request identity +// from the client IP (see resolveRequestUser in aiGateway/pipeline.ts) - +// and it's the only inference router that doesn't already run Badger. +export const AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME = "ai-gateway-client-ip"; + +/** + * The AI gateway may live on a different host than the inference resource + * itself (e.g. a remote exit node forwarding to the central dashboard over + * a tunnel), so callers use this to decide whether to pin the Host header + * to the gateway's own host. + */ +export function getAiGatewayHost(aiGatewayUrl: string): string | undefined { + try { + return new URL(aiGatewayUrl).host; + } catch { + return undefined; + } +} + +/** + * Header middleware that pins the Host header to the AI gateway's own host + * (when it differs from the resource's) and smuggles the original resource + * host through in "p-host" instead, so passHostHeader can't leak the wrong + * Host to a gateway that lives on a different host than the resource. + */ +export function buildAiGatewayHostHeaderMiddleware( + aiGatewayHost: string | undefined, + fullDomain: string +): { headers: { customRequestHeaders: Record } } { + return { + headers: { + customRequestHeaders: { + ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), + "p-host": fullDomain + } + } + }; +} + +export function buildAiGatewayTrustMiddlewares(): Record { + const token = getAiGatewayTrustToken(); + return { + [AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE]: { + headers: { + customRequestHeaders: { + [AI_GATEWAY_TRUST_HEADER]: token, + [AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource" + } + } + }, + [AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE]: { + headers: { + customRequestHeaders: { + [AI_GATEWAY_TRUST_HEADER]: token, + [AI_GATEWAY_RESOURCE_TYPE_HEADER]: "site-resource" + } + } + } + }; +} + +export function buildAiGatewayClientIpMiddleware(): Record | null { + const enabled = + config.getRawConfig().server.enable_ai_gateway_client_ip_header; + if (!enabled) { + return null; + } + return { + [AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME]: { + plugin: { + badger: { + disableForwardAuth: true, + realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER + } + } + } + }; +} + +/** + * Build the redirect (if ssl), main router, and single-server service for + * an AI-gateway-backed inference router. Identical between the public + * inference-resource and siteResource-inference cases, and between the OSS + * and private config generators - only the rule/tls/middleware chain + * differs, which callers resolve themselves beforehand. + */ +export function buildAiGatewayRouterAndService(params: { + routerName: string; + serviceName: string; + rule: string; + ssl: boolean | null; + tls: any; + priority: number; + routerMiddlewares: string[]; + aiGatewayUrl: string; + redirectHttpsMiddlewareName: string; +}): { routers: Record; services: Record } { + const { + routerName, + serviceName, + rule, + ssl, + tls, + priority, + routerMiddlewares, + aiGatewayUrl, + redirectHttpsMiddlewareName + } = params; + + const routers: Record = {}; + + if (ssl) { + routers[`${routerName}-redirect`] = { + entryPoints: [config.getRawConfig().traefik.http_entrypoint], + middlewares: [redirectHttpsMiddlewareName], + service: serviceName, + rule, + priority + }; + } + + routers[routerName] = { + entryPoints: [ + ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint + ], + middlewares: routerMiddlewares, + service: serviceName, + rule, + priority, + ...(ssl ? { tls } : {}) + }; + + const services = { + [serviceName]: { + loadBalancer: { + servers: [{ url: aiGatewayUrl }] + } + } + }; + + return { routers, services }; +} diff --git a/server/lib/traefik/browserGateway.ts b/server/lib/traefik/browserGateway.ts new file mode 100644 index 000000000..98a6690a6 --- /dev/null +++ b/server/lib/traefik/browserGateway.ts @@ -0,0 +1,399 @@ +import config from "@server/lib/config"; +import { sanitize } from "./utils"; + +export type BrowserGatewayResourceRow = { + resourceId: number; + resourceName: string | null; + mode: string; + fullDomain: string | null; + ssl: boolean | null; + subdomain: string | null; + domainId: string | null; + enabled: boolean | null; + wildcard: boolean | null; + domainCertResolver: string | null; + preferWildcardCert: boolean | null; + maintenanceModeEnabled: boolean | null; + maintenanceModeType: string | null; + maintenanceTitle: string | null; + maintenanceMessage: string | null; + maintenanceEstimatedTime: string | null; + targetId: number; + siteId: number; + siteType: string; + siteOnline: boolean | null; + subnet: string | null; + // Cloud-only namespace field - absent on OSS rows, so the namespace + // filter below naturally no-ops there. + domainNamespaceId?: unknown; +}; + +export type BrowserGatewayResourceEntry = { + resourceId: number; + name: string; + fullDomain: string | null; + ssl: boolean | null; + subdomain: string | null; + domainId: string | null; + enabled: boolean | null; + wildcard: boolean | null; + domainCertResolver: string | null; + preferWildcardCert: boolean | null; + maintenanceModeEnabled: boolean | null; + maintenanceModeType: string | null; + maintenanceTitle: string | null; + maintenanceMessage: string | null; + maintenanceEstimatedTime: string | null; + targets: { + targetId: number; + bgType: string; + siteId: number; + siteType: string; + siteOnline: boolean | null; + subnet: string | null; + }[]; +}; + +/** + * Group the raw resource/target/site rows into per-resource browser-gateway + * entries (SSH/VNC/RDP-mode resources served through the browser gateway + * web UI instead of a real backend target). + */ +export function buildBrowserGatewayResourcesMap( + rows: BrowserGatewayResourceRow[], + filterOutNamespaceDomains: boolean +): Map { + const map = new Map(); + + for (const row of rows) { + if (!["ssh", "vnc", "rdp"].includes(row.mode)) { + continue; + } + if (filterOutNamespaceDomains && row.domainNamespaceId) { + continue; + } + if (!map.has(row.resourceId)) { + map.set(row.resourceId, { + resourceId: row.resourceId, + name: sanitize(row.resourceName ?? undefined) || "", + fullDomain: row.fullDomain, + ssl: row.ssl, + subdomain: row.subdomain, + domainId: row.domainId, + enabled: row.enabled, + wildcard: row.wildcard, + domainCertResolver: row.domainCertResolver, + preferWildcardCert: row.preferWildcardCert, + maintenanceModeEnabled: row.maintenanceModeEnabled, + maintenanceModeType: row.maintenanceModeType, + maintenanceTitle: row.maintenanceTitle, + maintenanceMessage: row.maintenanceMessage, + maintenanceEstimatedTime: row.maintenanceEstimatedTime, + targets: [] + }); + } + map.get(row.resourceId)!.targets.push({ + targetId: row.targetId, + bgType: row.mode, + siteId: row.siteId, + siteType: row.siteType, + siteOnline: row.siteOnline, + subnet: row.subnet + }); + } + + return map; +} + +/** + * Build the Traefik routers/services for browser-gateway resources + * (SSH/VNC/RDP served via a browser-based client instead of a raw target), + * mutating config_output. TLS/cert-resolver handling differs between the + * OSS (always resolve directly) and private (pangolin-dns aware) config + * generators, so callers resolve that themselves via resolveTls - returning + * null skips the resource (no valid cert available yet). + */ +export function buildBrowserGatewayConfig(params: { + config_output: any; + browserGatewayResourcesMap: Map; + browserGatewayUiUrl: string; + maintenancePageUiUrl: string | null; + badgerMiddlewareName: string; + redirectHttpsMiddlewareName: string; + resolveTls: (args: { + fullDomain: string; + hasSubdomain: boolean; + domainCertResolver: string | null; + preferWildcardCert: boolean | null; + }) => any | null; +}): void { + const { + config_output, + browserGatewayResourcesMap, + browserGatewayUiUrl, + maintenancePageUiUrl, + badgerMiddlewareName, + redirectHttpsMiddlewareName, + resolveTls + } = params; + + const bgRateLimitMiddlewareName = "bg-ratelimit"; + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + if (!config_output.http.middlewares[bgRateLimitMiddlewareName]) { + const traefikRateLimit = config.getRawConfig().traefik.rate_limit; + config_output.http.middlewares[bgRateLimitMiddlewareName] = { + rateLimit: { + average: traefikRateLimit.average, + burst: traefikRateLimit.burst + } + }; + } + + const browserGatewayPort = 39999; + + for (const [, bgResource] of browserGatewayResourcesMap.entries()) { + if (!bgResource.enabled) continue; + if (!bgResource.domainId) continue; + if (!bgResource.fullDomain) continue; + + if (!config_output.http.routers) config_output.http.routers = {}; + if (!config_output.http.services) config_output.http.services = {}; + + const fullDomain = bgResource.fullDomain; + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + const routerMiddlewares = [ + badgerMiddlewareName, + bgRateLimitMiddlewareName, + ...additionalMiddlewares + ]; + + const hostRule = `Host(\`${fullDomain}\`)`; + + // Build TLS config + const tls = resolveTls({ + fullDomain, + hasSubdomain: !!bgResource.subdomain, + domainCertResolver: bgResource.domainCertResolver, + preferWildcardCert: bgResource.preferWildcardCert + }); + if (tls === null) { + continue; + } + + const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`; + + if (bgResource.ssl) { + const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`; + config_output.http.routers![redirectRouterName] = { + entryPoints: [config.getRawConfig().traefik.http_entrypoint], + middlewares: [redirectHttpsMiddlewareName], + service: bgUiServiceName, + rule: hostRule, + priority: 100 + }; + } + + // Collect online sites for this resource (for any type) + const anySiteOnline = bgResource.targets.some((t) => t.siteOnline); + + // Maintenance page logic for browser gateway resources + let showBgMaintenancePage = false; + if (bgResource.maintenanceModeEnabled) { + if (bgResource.maintenanceModeType === "forced") { + showBgMaintenancePage = true; + } else if (bgResource.maintenanceModeType === "automatic") { + showBgMaintenancePage = !anySiteOnline; + } + } + + if (showBgMaintenancePage && maintenancePageUiUrl) { + const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`; + const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`; + const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`; + const bgMaintenanceHeadersMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-headers`; + + const entrypointHttp = + config.getRawConfig().traefik.http_entrypoint; + const entrypointHttps = + config.getRawConfig().traefik.https_entrypoint; + + if (!config_output.http.services) config_output.http.services = {}; + if (!config_output.http.middlewares) + config_output.http.middlewares = {}; + if (!config_output.http.routers) config_output.http.routers = {}; + + config_output.http.services![bgMaintenanceServiceName] = { + loadBalancer: { + servers: [ + { + url: maintenancePageUiUrl + } + ], + passHostHeader: true + } + }; + + config_output.http.middlewares![bgRewriteMiddlewareName] = { + replacePathRegex: { + regex: "^/(.*)", + replacement: "/maintenance-screen" + } + }; + + config_output.http.middlewares![ + bgMaintenanceHeadersMiddlewareName + ] = { + headers: { + customRequestHeaders: { + Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource + "p-host": fullDomain + } + } + }; + + config_output.http.routers![bgMaintenanceRouterName] = { + entryPoints: [ + bgResource.ssl ? entrypointHttps : entrypointHttp + ], + service: bgMaintenanceServiceName, + middlewares: [ + bgRewriteMiddlewareName, + bgMaintenanceHeadersMiddlewareName + ], + rule: hostRule, + priority: 2000, + ...(bgResource.ssl ? { tls } : {}) + }; + + // Router to allow Next.js assets to load without rewrite + config_output.http.routers![`${bgMaintenanceRouterName}-assets`] = { + entryPoints: [ + bgResource.ssl ? entrypointHttps : entrypointHttp + ], + service: bgMaintenanceServiceName, + middlewares: [bgMaintenanceHeadersMiddlewareName], + rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, + priority: 2001, + ...(bgResource.ssl ? { tls } : {}) + }; + + continue; + } + + // Group targets by type and generate per-type websocket routers and services + const typeMap = new Map(); + for (const t of bgResource.targets) { + if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []); + typeMap.get(t.bgType)!.push(t); + } + + for (const [bgType, typedTargets] of typeMap.entries()) { + const bgKey = `bg-r${bgResource.resourceId}-${bgType}`; + const bgRouterName = `${bgKey}-router`; + const bgServiceName = `${bgKey}-service`; + const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`; + + const servers = typedTargets + .filter((t) => { + if (!t.siteOnline && anySiteOnline) return false; + if (t.siteType === "newt") return !!t.subnet; + return false; // browser gateway only supported on newt sites + }) + .map((t) => ({ + url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}` + })) + .filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i); + + config_output.http.routers![bgRouterName] = { + entryPoints: [ + bgResource.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint + ], + middlewares: routerMiddlewares, + service: bgServiceName, + rule: bgRule, + priority: 110, // highest - websocket path takes precedence + ...(bgResource.ssl ? { tls } : {}) + }; + + config_output.http.services![bgServiceName] = { + loadBalancer: { + servers + } + }; + } + + // UI: serve the browser gateway page from the internal pangolin instance. + // The primary type is used for the path rewrite (e.g. /rdp), mirroring + // how the maintenance page rewrites everything to /maintenance-screen. + const primaryType = typeMap.keys().next().value as string; + const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`; + const uiHeadersMiddlewareName = `bg-r${bgResource.resourceId}-ui-headers`; + const entrypoint = bgResource.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint; + + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + + config_output.http.middlewares![uiRewriteMiddlewareName] = { + replacePathRegex: { + regex: "^/(.*)", + replacement: `/${primaryType}` + } + }; + + config_output.http.middlewares![uiHeadersMiddlewareName] = { + headers: { + customRequestHeaders: { + Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource + "p-host": fullDomain + } + } + }; + + config_output.http.services![bgUiServiceName] = { + loadBalancer: { + servers: [ + { + url: browserGatewayUiUrl + } + ] + } + }; + + // Assets router at higher priority so /_next files load without rewrite. + // Do NOT apply the path-rewrite middleware here — static assets must + // keep their original path; only the host headers are needed. + config_output.http.routers![ + `bg-r${bgResource.resourceId}-assets-router` + ] = { + entryPoints: [entrypoint], + middlewares: [...routerMiddlewares, uiHeadersMiddlewareName], + service: bgUiServiceName, + rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, + priority: 101, + ...(bgResource.ssl ? { tls } : {}) + }; + + // Catch-all router rewrites everything on the domain to /{primaryType} + config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] = + { + entryPoints: [entrypoint], + middlewares: [ + ...routerMiddlewares, + uiRewriteMiddlewareName, + uiHeadersMiddlewareName + ], + service: bgUiServiceName, + rule: hostRule, + priority: 100, + ...(bgResource.ssl ? { tls } : {}) + }; + } +} diff --git a/server/lib/traefik/certResolver.ts b/server/lib/traefik/certResolver.ts new file mode 100644 index 000000000..940512dc8 --- /dev/null +++ b/server/lib/traefik/certResolver.ts @@ -0,0 +1,44 @@ +import config from "@server/lib/config"; + +/** + * Build the Traefik `tls` block for a domain using the cert-resolver / + * wildcard-cert logic shared by both the OSS and private Traefik config + * generators (used whenever certs are obtained directly via ACME rather + * than through pangolin-dns). + */ +export function buildWildcardTls(params: { + fullDomain: string; + hasSubdomain: boolean; + domainCertResolver?: string | null; + preferWildcardCert?: boolean | null; +}): { certResolver: string | undefined; domains?: { main: string }[] } { + const { fullDomain, hasSubdomain, domainCertResolver, preferWildcardCert } = + params; + + const domainParts = fullDomain.split("."); + let wildCard = + domainParts.length <= 2 + ? `*.${domainParts.join(".")}` + : `*.${domainParts.slice(1).join(".")}`; + if (!hasSubdomain) { + wildCard = fullDomain; + } + + const globalDefaultResolver = config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; + + const resolverName = domainCertResolver + ? domainCertResolver.trim() + : globalDefaultResolver; + + const preferWildcard = + preferWildcardCert !== undefined && preferWildcardCert !== null + ? preferWildcardCert + : globalDefaultPreferWildcard; + + return { + certResolver: resolverName, + ...(preferWildcard ? { domains: [{ main: wildCard }] } : {}) + }; +} diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index da9fb0810..b755ea341 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -5,6 +5,7 @@ import { aiProviders, resourceAiProviders, siteResources, + siteNetworks, exitNodes } from "@server/db"; import { @@ -20,47 +21,47 @@ import { } from "drizzle-orm"; import logger from "@server/logger"; import config from "@server/lib/config"; -import { resources, sites, Target, targets } from "@server/db"; -import createPathRewriteMiddleware from "./middleware"; +import { resources, sites, targets } from "@server/db"; +import { applyPathRewriteMiddleware } from "./middleware"; import { sanitize, encodePath, validatePathRewriteConfig } from "./utils"; import regionalCache from "@server/lib/cache"; +import { TargetWithSite } from "./types"; +import { buildWildcardTls } from "./certResolver"; +import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule"; import { - AI_GATEWAY_TRUST_HEADER, - AI_GATEWAY_RESOURCE_TYPE_HEADER, - AI_GATEWAY_CLIENT_IP_HEADER, - getAiGatewayTrustToken -} from "@server/lib/aiGatewayTrust"; + buildHttpLoadBalancerServers, + buildStickySessionCookie, + buildTcpUdpLoadBalancerServers, + buildStickySessionIp +} from "./loadBalancer"; +import { buildCustomHeadersMiddleware } from "./headersMiddleware"; +import { + AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE, + AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE, + AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME, + getAiGatewayHost, + buildAiGatewayTrustMiddlewares, + buildAiGatewayClientIpMiddleware, + buildAiGatewayHostHeaderMiddleware, + buildAiGatewayRouterAndService +} from "./aiGatewayMiddlewares"; +import { + buildBrowserGatewayResourcesMap, + buildBrowserGatewayConfig +} from "./browserGateway"; +import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias"; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; -// Define extended target type with site information -type TargetWithSite = Target & { - resourceId: number; - targetId: number; - ip: string | null; - method: string | null; - port: number | null; - internalPort: number | null; - enabled: boolean; - health: string | null; - site: { - siteId: number; - type: string; - subnet: string | null; - exitNodeId: number | null; - online: boolean; - }; -}; - export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE allowRawResources = true, - maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE - browserGatewayUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE + maintenancePageUiUrl: string | null = null, + browserGatewayUiUrl: string | null = null, aiGatewayUrl: string | null = null ): Promise { // Get the exit node but cache it for 5 minutes to avoid hitting the DB too often @@ -98,8 +99,15 @@ export async function getTraefikConfig( headers: resources.headers, proxyProtocol: resources.proxyProtocol, proxyProtocolVersion: resources.proxyProtocolVersion, + wildcard: resources.wildcard, mode: resources.mode, + maintenanceModeEnabled: resources.maintenanceModeEnabled, + maintenanceModeType: resources.maintenanceModeType, + maintenanceTitle: resources.maintenanceTitle, + maintenanceMessage: resources.maintenanceMessage, + maintenanceEstimatedTime: resources.maintenanceEstimatedTime, + // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, @@ -146,8 +154,15 @@ export async function getTraefikConfig( ), inArray(sites.type, siteTypes), allowRawResources - ? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three - : eq(resources.mode, "http") + ? inArray(resources.mode, [ + "http", + "udp", + "tcp", + "vnc", + "ssh", + "rdp" + ]) // allow all three, plus browser-gateway modes + : inArray(resources.mode, ["http", "vnc", "ssh", "rdp"]) ) ) .orderBy(desc(targets.priority), targets.targetId); // stable ordering @@ -156,6 +171,9 @@ export async function getTraefikConfig( const resourcesMap = new Map(); resourcesWithTargetsAndSites.forEach((row) => { + if (!["http", "tcp", "udp"].includes(row.mode)) { + return; + } const resourceId = row.resourceId; const resourceName = sanitize(row.resourceName) || ""; const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b") @@ -240,6 +258,40 @@ export async function getTraefikConfig( }); }); + // Group browser gateway targets by resource (SSH/VNC/RDP-mode resources + // served through the browser gateway web UI instead of a real target). + const browserGatewayResourcesMap = browserGatewayUiUrl + ? buildBrowserGatewayResourcesMap( + resourcesWithTargetsAndSites, + filterOutNamespaceDomains + ) + : new Map(); + + // Query siteResources in HTTP mode with SSL enabled and aliases, so + // Traefik generates TLS certificates for those domains even before a + // matching resource exists. + const siteResourcesWithFullDomain = await db + .select({ + siteResourceId: siteResources.siteResourceId, + fullDomain: siteResources.fullDomain + }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where( + and( + eq(siteResources.enabled, true), + isNotNull(siteResources.fullDomain), + eq(siteResources.mode, "http"), // important so we dont double get the inference siteResources below + eq(siteResources.ssl, true), + eq(sites.exitNodeId, exitNodeId), + inArray(sites.type, siteTypes) + ) + ); + // Inference-mode resources have no targets/sites (their "backend" is the // central AI gateway), so they can't be reached via the targets->sites // join above - query them separately and include them on every exit node. @@ -275,7 +327,12 @@ export async function getTraefikConfig( ); // make sure we have at least one resource - if (resourcesMap.size === 0 && inferenceResources.length === 0) { + if ( + resourcesMap.size === 0 && + inferenceResources.length === 0 && + browserGatewayResourcesMap.size === 0 && + siteResourcesWithFullDomain.length === 0 + ) { return {}; } @@ -319,56 +376,12 @@ export async function getTraefikConfig( config_output.http.services = {}; } - const domainParts = fullDomain.split("."); - let wildCard; - if (domainParts.length <= 2) { - wildCard = `*.${domainParts.join(".")}`; - } else { - wildCard = `*.${domainParts.slice(1).join(".")}`; - } - - if (!resource.subdomain) { - wildCard = resource.fullDomain; - } - - const globalDefaultResolver = - config.getRawConfig().traefik.cert_resolver; - const globalDefaultPreferWildcard = - config.getRawConfig().traefik.prefer_wildcard_cert; - - const domainCertResolver = resource.domainCertResolver; - const preferWildcardCert = resource.preferWildcardCert; - - let resolverName: string | undefined; - let preferWildcard: boolean | undefined; - // Handle both letsencrypt & custom cases - if (domainCertResolver) { - resolverName = domainCertResolver.trim(); - } else { - resolverName = globalDefaultResolver; - } - - if ( - preferWildcardCert !== undefined && - preferWildcardCert !== null - ) { - preferWildcard = preferWildcardCert; - } else { - preferWildcard = globalDefaultPreferWildcard; - } - - const tls = { - certResolver: resolverName, - ...(preferWildcard - ? { - domains: [ - { - main: wildCard - } - ] - } - : {}) - }; + const tls = buildWildcardTls({ + fullDomain, + hasSubdomain: !!resource.subdomain, + domainCertResolver: resource.domainCertResolver, + preferWildcardCert: resource.preferWildcardCert + }); const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; @@ -379,134 +392,40 @@ export async function getTraefikConfig( ]; // Handle path rewriting middleware - if ( - resource.rewritePath !== null && - resource.path !== null && - resource.pathMatchType && - resource.rewritePathType - ) { - // Create a unique middleware name - const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${key}`; - - try { - const rewriteResult = createPathRewriteMiddleware( - rewriteMiddlewareName, - resource.path, - resource.pathMatchType, - resource.rewritePath, - resource.rewritePathType - ); - - // Initialize middlewares object if it doesn't exist - if (!config_output.http.middlewares) { - config_output.http.middlewares = {}; - } - - // the middleware to the config - Object.assign( - config_output.http.middlewares, - rewriteResult.middlewares - ); - - // middlewares to the router middleware chain - if (rewriteResult.chain) { - // For chained middlewares (like stripPrefix + addPrefix) - routerMiddlewares.push(...rewriteResult.chain); - } else { - // Single middleware - routerMiddlewares.push(rewriteMiddlewareName); - } - - // logger.debug( - // `Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})` - // ); - } catch (error) { - logger.error( - `Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}` - ); - } - } + applyPathRewriteMiddleware( + config_output, + resource.resourceId, + key, + resource.path, + resource.pathMatchType, + resource.rewritePath, + resource.rewritePathType, + routerMiddlewares + ); // Handle custom headers middleware - if (resource.headers || resource.setHostHeader) { - const headersObj: { [key: string]: string } = {}; - - if (resource.headers) { - let headersArr: { name: string; value: string }[] = []; - try { - headersArr = JSON.parse(resource.headers) as { - name: string; - value: string; - }[]; - } catch (e) { - logger.warn( - `Failed to parse headers for resource ${resource.resourceId}: ${e}` - ); - } - - headersArr.forEach((header) => { - headersObj[header.name] = header.value; - }); - } - - if (resource.setHostHeader) { - headersObj["Host"] = resource.setHostHeader; - } - - if (Object.keys(headersObj).length > 0) { - if (!config_output.http.middlewares) { - config_output.http.middlewares = {}; - } - config_output.http.middlewares[headersMiddlewareName] = { - headers: { - customRequestHeaders: headersObj - } - }; - - routerMiddlewares.push(headersMiddlewareName); + const customHeadersMiddleware = buildCustomHeadersMiddleware( + resource.headers, + resource.setHostHeader, + resource.resourceId + ); + if (customHeadersMiddleware) { + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; } + config_output.http.middlewares[headersMiddlewareName] = + customHeadersMiddleware; + routerMiddlewares.push(headersMiddlewareName); } // Build routing rules - let rule = `Host(\`${fullDomain}\`)`; - - // priority logic - let priority: number; - if (resource.priority && resource.priority != 100) { - priority = resource.priority; - } else { - priority = 100; - if (resource.path && resource.pathMatchType) { - priority += 10; - if (resource.pathMatchType === "exact") { - priority += 5; - } else if (resource.pathMatchType === "prefix") { - priority += 3; - } else if (resource.pathMatchType === "regex") { - priority += 2; - } - if (resource.path === "/") { - priority = 1; // lowest for catch-all - } - } - } - - if (resource.path && resource.pathMatchType) { - // priority += 1; - // add path to rule based on match type - let path = resource.path; - // if the path doesn't start with a /, add it - if (!path.startsWith("/")) { - path = `/${path}`; - } - if (resource.pathMatchType === "exact") { - rule += ` && Path(\`${path}\`)`; - } else if (resource.pathMatchType === "prefix") { - rule += ` && PathPrefix(\`${path}\`)`; - } else if (resource.pathMatchType === "regex") { - rule += ` && PathRegexp(\`${resource.path}\`)`; // this is the raw path because it's a regex - } - } + let rule = buildHostRule(fullDomain); + const priority = computeRoutePriority( + resource.priority, + resource.path, + resource.pathMatchType + ); + rule = appendPathMatch(rule, resource.path, resource.pathMatchType); config_output.http.routers![routerName] = { entryPoints: [ @@ -535,90 +454,9 @@ export async function getTraefikConfig( config_output.http.services![serviceName] = { loadBalancer: { - servers: (() => { - // Check if any sites are online - // THIS IS SO THAT THERE IS SOME IMMEDIATE FEEDBACK - // EVEN IF THE SITES HAVE NOT UPDATED YET FROM THE - // RECEIVE BANDWIDTH ENDPOINT. - - // TODO: HOW TO HANDLE ^^^^^^ BETTER - const anySitesOnline = targets.some( - (target) => target.site.online - ); - - return ( - targets - .filter((target) => { - if (!target.enabled) { - return false; - } - - if (target.health == "unhealthy") { - return false; - } - - // If any sites are online, exclude offline sites - if (anySitesOnline && !target.site.online) { - return false; - } - - if ( - target.site.type === "local" || - target.site.type === "wireguard" - ) { - if ( - !target.ip || - !target.port || - !target.method - ) { - return false; - } - } else if (target.site.type === "newt") { - if ( - !target.internalPort || - !target.method || - !target.site.subnet - ) { - return false; - } - } - return true; - }) - .map((target) => { - if ( - target.site.type === "local" || - target.site.type === "wireguard" - ) { - return { - url: `${target.method}://${target.ip}:${target.port}` - }; - } else if (target.site.type === "newt") { - const ip = - target.site.subnet!.split("/")[0]; - return { - url: `${target.method}://${ip}:${target.internalPort}` - }; - } - }) - // filter out duplicates - .filter( - (v, i, a) => - a.findIndex( - (t) => t && v && t.url === v.url - ) === i - ) - ); - })(), + servers: buildHttpLoadBalancerServers(targets), ...(resource.stickySession - ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } + ? buildStickySessionCookie(resource.ssl) : {}) } }; @@ -668,77 +506,67 @@ export async function getTraefikConfig( config_output[protocol].services[serviceName] = { loadBalancer: { - servers: (() => { - // Check if any sites are online - const anySitesOnline = targets.some( - (target) => target.site.online - ); - - return targets - .filter((target) => { - if (!target.enabled) { - return false; - } - - // If any sites are online, exclude offline sites - if (anySitesOnline && !target.site.online) { - return false; - } - - if ( - target.site.type === "local" || - target.site.type === "wireguard" - ) { - if (!target.ip || !target.port) { - return false; - } - } else if (target.site.type === "newt") { - if ( - !target.internalPort || - !target.site.subnet - ) { - return false; - } - } - return true; - }) - .map((target) => { - if ( - target.site.type === "local" || - target.site.type === "wireguard" - ) { - return { - address: `${target.ip}:${target.port}` - }; - } else if (target.site.type === "newt") { - const ip = - target.site.subnet!.split("/")[0]; - return { - address: `${ip}:${target.internalPort}` - }; - } - }); - })(), + servers: buildTcpUdpLoadBalancerServers(targets), ...(resource.proxyProtocol && protocol == "tcp" ? { serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues? } : {}), - ...(resource.stickySession - ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } - : {}) + ...(resource.stickySession ? buildStickySessionIp() : {}) } }; } } + if (browserGatewayUiUrl) { + buildBrowserGatewayConfig({ + config_output, + browserGatewayResourcesMap, + browserGatewayUiUrl, + maintenancePageUiUrl, + badgerMiddlewareName, + redirectHttpsMiddlewareName, + resolveTls: ({ + fullDomain, + hasSubdomain, + domainCertResolver, + preferWildcardCert + }) => + buildWildcardTls({ + fullDomain, + hasSubdomain, + domainCertResolver, + preferWildcardCert + }) + }); + } + + // Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that + // Traefik generates TLS certificates for those domains even when no + // matching resource exists yet. + if (siteResourcesWithFullDomain.length > 0) { + // Build a set of domains already covered by normal resources + const existingFullDomains = new Set(); + for (const resource of resourcesMap.values()) { + if (resource.fullDomain) { + existingFullDomains.add(resource.fullDomain); + } + } + + buildSiteResourceAliasCertPlaceholders({ + config_output, + siteResourcesWithFullDomain, + existingFullDomains, + maintenancePageUiUrl, + redirectHttpsMiddlewareName, + resolveTls: (fullDomain) => + buildWildcardTls({ + fullDomain, + hasSubdomain: true + }) + }); + } + if (aiGatewayUrl) { // The AI gateway may live on a different host than the inference // resource itself (e.g. a remote exit node forwarding to the @@ -747,64 +575,23 @@ export async function getTraefikConfig( // recognize, so we pin the Host header to the gateway's own host // and smuggle the original resource host through in "p-host" // instead. - let aiGatewayHost: string | undefined; - try { - aiGatewayHost = new URL(aiGatewayUrl).host; - } catch { - aiGatewayHost = undefined; - } + const aiGatewayHost = getAiGatewayHost(aiGatewayUrl); - // The trust token is the same for every inference route on this exit - // node, so it's defined once here and attached to each router below - // instead of being duplicated into a per-resource middleware. Two - // variants exist (public resource vs. siteResource) so the resource - // type header lets the gateway know which kind of router the - // request came through without re-deriving it from resourceId. - const aiGatewayTrustMiddlewareNameResource = - "ai-gateway-trust-headers-resource"; - const aiGatewayTrustMiddlewareNameSiteResource = - "ai-gateway-trust-headers-site-resource"; if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } - config_output.http.middlewares[aiGatewayTrustMiddlewareNameResource] = { - headers: { - customRequestHeaders: { - [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(), - [AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource" - } - } - }; - config_output.http.middlewares[ - aiGatewayTrustMiddlewareNameSiteResource - ] = { - headers: { - customRequestHeaders: { - [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(), - [AI_GATEWAY_RESOURCE_TYPE_HEADER]: "site-resource" - } - } - }; + Object.assign( + config_output.http.middlewares, + buildAiGatewayTrustMiddlewares() + ); - // Opt-in: a Badger instance with forward auth disabled, used only - // to stamp the resolved client IP into a dedicated header before - // the request reaches whatever sits between Traefik and the AI - // gateway. Only the site-resource router below needs this - it's - // the only path that resolves request identity from the client IP - // (see resolveRequestUser in aiGateway/pipeline.ts) - and it's the - // only inference router that doesn't already run Badger. - const aiGatewayClientIpMiddlewareName = "ai-gateway-client-ip"; - const enableAiGatewayClientIpHeader = - config.getRawConfig().server.enable_ai_gateway_client_ip_header; - if (enableAiGatewayClientIpHeader) { - config_output.http.middlewares[aiGatewayClientIpMiddlewareName] = { - plugin: { - badger: { - disableForwardAuth: true, - realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER - } - } - }; + const aiGatewayClientIpMiddleware = buildAiGatewayClientIpMiddleware(); + const enableAiGatewayClientIpHeader = !!aiGatewayClientIpMiddleware; + if (aiGatewayClientIpMiddleware) { + Object.assign( + config_output.http.middlewares, + aiGatewayClientIpMiddleware + ); } // Public inference resources: same TLS/cert-resolver handling as @@ -822,95 +609,41 @@ export async function getTraefikConfig( const routerName = `${irKey}-router`; const serviceName = `${irKey}-service`; - let rule: string; - if (ir.wildcard && fullDomain.startsWith("*.")) { - const escaped = fullDomain.slice(2).replace(/\./g, "\\."); - rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`; - } else { - rule = `Host(\`${fullDomain}\`)`; - } + const rule = buildHostRule(fullDomain, ir.wildcard); - const domainParts = fullDomain.split("."); - let wildCard; - if (domainParts.length <= 2) { - wildCard = `*.${domainParts.join(".")}`; - } else { - wildCard = `*.${domainParts.slice(1).join(".")}`; - } - if (!ir.subdomain) { - wildCard = fullDomain; - } - - const globalDefaultResolver = - config.getRawConfig().traefik.cert_resolver; - const globalDefaultPreferWildcard = - config.getRawConfig().traefik.prefer_wildcard_cert; - const resolverName = ir.domainCertResolver - ? ir.domainCertResolver.trim() - : globalDefaultResolver; - const preferWildcard = - ir.preferWildcardCert !== undefined && - ir.preferWildcardCert !== null - ? ir.preferWildcardCert - : globalDefaultPreferWildcard; - - const tls = { - certResolver: resolverName, - ...(preferWildcard ? { domains: [{ main: wildCard }] } : {}) - }; + const tls = buildWildcardTls({ + fullDomain, + hasSubdomain: !!ir.subdomain, + domainCertResolver: ir.domainCertResolver, + preferWildcardCert: ir.preferWildcardCert + }); const irHeadersMiddlewareName = `${irKey}-headers-middleware`; - if (!config_output.http.middlewares) { - config_output.http.middlewares = {}; - } - config_output.http.middlewares[irHeadersMiddlewareName] = { - headers: { - customRequestHeaders: { - ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": fullDomain - } - } - }; + config_output.http.middlewares[irHeadersMiddlewareName] = + buildAiGatewayHostHeaderMiddleware(aiGatewayHost, fullDomain); const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares = [ badgerMiddlewareName, - aiGatewayTrustMiddlewareNameResource, + AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE, irHeadersMiddlewareName, ...additionalMiddlewares ]; - if (ir.ssl) { - config_output.http.routers[routerName + "-redirect"] = { - entryPoints: [ - config.getRawConfig().traefik.http_entrypoint - ], - middlewares: [redirectHttpsMiddlewareName], - service: serviceName, - rule, - priority: 100 - }; - } - - config_output.http.routers[routerName] = { - entryPoints: [ - ir.ssl - ? config.getRawConfig().traefik.https_entrypoint - : config.getRawConfig().traefik.http_entrypoint - ], - middlewares: routerMiddlewares, - service: serviceName, + const { routers, services } = buildAiGatewayRouterAndService({ + routerName, + serviceName, rule, + ssl: ir.ssl, + tls, priority: 100, - ...(ir.ssl ? { tls } : {}) - }; - - config_output.http.services[serviceName] = { - loadBalancer: { - servers: [{ url: aiGatewayUrl }] - } - }; + routerMiddlewares, + aiGatewayUrl, + redirectHttpsMiddlewareName + }); + Object.assign(config_output.http.routers, routers); + Object.assign(config_output.http.services, services); } // Private (siteResource) inference resources: routed by their alias @@ -946,80 +679,49 @@ export async function getTraefikConfig( const srKey = `inference-sr${sr.siteResourceId}`; const routerName = `${srKey}-router`; const serviceName = `${srKey}-service`; - const rule = `Host(\`${fullDomain}\`) && ClientIP(${exitNode.address})`; // restrict to coming from the exit node ip range that the client is connected to + const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to - const domainParts = fullDomain.split("."); - const wildCard = - domainParts.length <= 2 - ? `*.${domainParts.join(".")}` - : `*.${domainParts.slice(1).join(".")}`; - - const globalDefaultResolver = - config.getRawConfig().traefik.cert_resolver; - const globalDefaultPreferWildcard = - config.getRawConfig().traefik.prefer_wildcard_cert; - - const tls = { - certResolver: globalDefaultResolver, - ...(globalDefaultPreferWildcard - ? { domains: [{ main: wildCard }] } - : {}) - }; + // siteResource aliases don't have a per-domain cert resolver + // stored, so always fall back to the global defaults. + const tls = buildWildcardTls({ + fullDomain, + hasSubdomain: true + }); const srHeadersMiddlewareName = `${srKey}-headers-middleware`; if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } - config_output.http.middlewares[srHeadersMiddlewareName] = { - headers: { - customRequestHeaders: { - ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": fullDomain - } - } - }; + config_output.http.middlewares[srHeadersMiddlewareName] = + buildAiGatewayHostHeaderMiddleware( + aiGatewayHost, + fullDomain + ); const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares = [ ...(enableAiGatewayClientIpHeader - ? [aiGatewayClientIpMiddlewareName] + ? [AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME] : []), - aiGatewayTrustMiddlewareNameSiteResource, + AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE, srHeadersMiddlewareName, ...additionalMiddlewares ]; - if (sr.ssl) { - config_output.http.routers[routerName + "-redirect"] = { - entryPoints: [ - config.getRawConfig().traefik.http_entrypoint - ], - middlewares: [redirectHttpsMiddlewareName], - service: serviceName, - rule, - priority: 200 // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. - }; - } - - config_output.http.routers[routerName] = { - entryPoints: [ - sr.ssl - ? config.getRawConfig().traefik.https_entrypoint - : config.getRawConfig().traefik.http_entrypoint - ], - middlewares: routerMiddlewares, - service: serviceName, + const { routers, services } = buildAiGatewayRouterAndService({ + routerName, + serviceName, rule, + ssl: sr.ssl, + tls, priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. - ...(sr.ssl ? { tls } : {}) - }; - - config_output.http.services[serviceName] = { - loadBalancer: { - servers: [{ url: aiGatewayUrl }] - } - }; + routerMiddlewares, + aiGatewayUrl, + redirectHttpsMiddlewareName + }); + Object.assign(config_output.http.routers, routers); + Object.assign(config_output.http.services, services); } } } diff --git a/server/lib/traefik/headersMiddleware.ts b/server/lib/traefik/headersMiddleware.ts new file mode 100644 index 000000000..84f34dff4 --- /dev/null +++ b/server/lib/traefik/headersMiddleware.ts @@ -0,0 +1,46 @@ +import logger from "@server/logger"; + +/** + * Build the customRequestHeaders middleware definition for a resource's + * custom headers + setHostHeader config. Returns null when there are no + * headers to set, so the caller can skip attaching the middleware. + */ +export function buildCustomHeadersMiddleware( + headers: string | null | undefined, + setHostHeader: string | null | undefined, + resourceId: number +): { headers: { customRequestHeaders: { [key: string]: string } } } | null { + const headersObj: { [key: string]: string } = {}; + + if (headers) { + let headersArr: { name: string; value: string }[] = []; + try { + headersArr = JSON.parse(headers) as { + name: string; + value: string; + }[]; + } catch (e) { + logger.warn( + `Failed to parse headers for resource ${resourceId}: ${e}` + ); + } + + headersArr.forEach((header) => { + headersObj[header.name] = header.value; + }); + } + + if (setHostHeader) { + headersObj["Host"] = setHostHeader; + } + + if (Object.keys(headersObj).length === 0) { + return null; + } + + return { + headers: { + customRequestHeaders: headersObj + } + }; +} diff --git a/server/lib/traefik/loadBalancer.ts b/server/lib/traefik/loadBalancer.ts new file mode 100644 index 000000000..af1454a18 --- /dev/null +++ b/server/lib/traefik/loadBalancer.ts @@ -0,0 +1,134 @@ +import { TargetWithSite } from "./types"; + +/** + * Build the loadBalancer.servers list for an HTTP-mode resource, preferring + * currently-online sites but falling back to all enabled/healthy targets if + * none are online yet (so there's still some feedback before sites report + * back over the receive-bandwidth endpoint). + */ +export function buildHttpLoadBalancerServers(targets: TargetWithSite[]) { + const anySitesOnline = targets.some((target) => target.site.online); + + return targets + .filter((target) => { + if (!target.enabled) { + return false; + } + + if (target.health == "unhealthy") { + return false; + } + + // If any sites are online, exclude offline sites + if (anySitesOnline && !target.site.online) { + return false; + } + + if ( + target.site.type === "local" || + target.site.type === "wireguard" + ) { + if (!target.ip || !target.port || !target.method) { + return false; + } + } else if (target.site.type === "newt") { + if ( + !target.internalPort || + !target.method || + !target.site.subnet + ) { + return false; + } + } + return true; + }) + .map((target) => { + if ( + target.site.type === "local" || + target.site.type === "wireguard" + ) { + return { + url: `${target.method}://${target.ip}:${target.port}` + }; + } else if (target.site.type === "newt") { + const ip = target.site.subnet!.split("/")[0]; + return { + url: `${target.method}://${ip}:${target.internalPort}` + }; + } + }) + .filter( + (v, i, a) => a.findIndex((t) => t && v && t.url === v.url) === i + ); +} + +export function buildStickySessionCookie(ssl: boolean | null) { + return { + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: ssl, + httpOnly: true + } + } + }; +} + +/** + * Build the loadBalancer.servers list for a TCP/UDP-mode resource. + */ +export function buildTcpUdpLoadBalancerServers(targets: TargetWithSite[]) { + const anySitesOnline = targets.some((target) => target.site.online); + + return targets + .filter((target) => { + if (!target.enabled) { + return false; + } + + // If any sites are online, exclude offline sites + if (anySitesOnline && !target.site.online) { + return false; + } + + if ( + target.site.type === "local" || + target.site.type === "wireguard" + ) { + if (!target.ip || !target.port) { + return false; + } + } else if (target.site.type === "newt") { + if (!target.internalPort || !target.site.subnet) { + return false; + } + } + return true; + }) + .map((target) => { + if ( + target.site.type === "local" || + target.site.type === "wireguard" + ) { + return { + address: `${target.ip}:${target.port}` + }; + } else if (target.site.type === "newt") { + const ip = target.site.subnet!.split("/")[0]; + return { + address: `${ip}:${target.internalPort}` + }; + } + }); +} + +export function buildStickySessionIp() { + return { + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + }; +} diff --git a/server/lib/traefik/middleware.ts b/server/lib/traefik/middleware.ts index e4055976e..1c62442be 100644 --- a/server/lib/traefik/middleware.ts +++ b/server/lib/traefik/middleware.ts @@ -1,5 +1,64 @@ import logger from "@server/logger"; +/** + * Create (if configured) and attach a path-rewrite middleware for a + * resource, mutating both config_output.http.middlewares and the + * router's middleware chain. Shared by the OSS and private Traefik config + * generators, which apply it identically. + */ +export function applyPathRewriteMiddleware( + config_output: any, + resourceId: number, + key: string, + path: string | null, + pathMatchType: string | null, + rewritePath: string | null, + rewritePathType: string | null, + routerMiddlewares: string[] +) { + if ( + rewritePath === null || + path === null || + !pathMatchType || + !rewritePathType + ) { + return; + } + + const rewriteMiddlewareName = `rewrite-r${resourceId}-${key}`; + + try { + const rewriteResult = createPathRewriteMiddleware( + rewriteMiddlewareName, + path, + pathMatchType, + rewritePath, + rewritePathType + ); + + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + + Object.assign( + config_output.http.middlewares, + rewriteResult.middlewares + ); + + if (rewriteResult.chain) { + // For chained middlewares (like stripPrefix + addPrefix) + routerMiddlewares.push(...rewriteResult.chain); + } else { + // Single middleware + routerMiddlewares.push(rewriteMiddlewareName); + } + } catch (error) { + logger.error( + `Failed to create path rewrite middleware for resource ${resourceId}: ${error}` + ); + } +} + export default function createPathRewriteMiddleware( middlewareName: string, path: string, diff --git a/server/lib/traefik/rule.ts b/server/lib/traefik/rule.ts new file mode 100644 index 000000000..5f9406db4 --- /dev/null +++ b/server/lib/traefik/rule.ts @@ -0,0 +1,71 @@ +/** + * Build the Host()/HostRegexp() Traefik rule for a resource's domain. + * Wildcard resources match any single subdomain via HostRegexp. + */ +export function buildHostRule( + fullDomain: string, + wildcard?: boolean | null +): string { + if (wildcard && fullDomain.startsWith("*.")) { + // Convert *.foo.bar.com -> HostRegexp(`^[^.]+\.foo\.bar\.com$`) + const escaped = fullDomain.slice(2).replace(/\./g, "\\."); + return `HostRegexp(\`^[^.]+\\.${escaped}$\`)`; + } + return `Host(\`${fullDomain}\`)`; +} + +/** + * Append a path-matching clause to a Traefik rule based on the resource's + * configured path and pathMatchType. + */ +export function appendPathMatch( + rule: string, + path: string | null | undefined, + pathMatchType: string | null | undefined +): string { + if (!path || !pathMatchType) return rule; + + let p = path; + if (!p.startsWith("/")) { + p = `/${p}`; + } + + if (pathMatchType === "exact") { + return `${rule} && Path(\`${p}\`)`; + } else if (pathMatchType === "prefix") { + return `${rule} && PathPrefix(\`${p}\`)`; + } else if (pathMatchType === "regex") { + return `${rule} && PathRegexp(\`${path}\`)`; // this is the raw path because it's a regex + } + return rule; +} + +/** + * Compute the router priority for a resource, favoring an explicit override + * and otherwise deriving it from the path match specificity. + */ +export function computeRoutePriority( + priority: number | null | undefined, + path: string | null | undefined, + pathMatchType: string | null | undefined +): number { + if (priority && priority != 100) { + return priority; + } + + let p = 100; + if (path && pathMatchType) { + p += 10; + if (pathMatchType === "exact") { + p += 5; + } else if (pathMatchType === "prefix") { + p += 3; + } else if (pathMatchType === "regex") { + p += 2; + } + if (path === "/") { + p = 1; // lowest for catch-all + } + } + return p; +} diff --git a/server/lib/traefik/siteResourceAlias.ts b/server/lib/traefik/siteResourceAlias.ts new file mode 100644 index 000000000..3c7392064 --- /dev/null +++ b/server/lib/traefik/siteResourceAlias.ts @@ -0,0 +1,114 @@ +import config from "@server/lib/config"; + +export type SiteResourceAliasRow = { + siteResourceId: number; + fullDomain: string | null; +}; + +/** + * Add placeholder Traefik routes for siteResource HTTP aliases so Traefik + * generates TLS certificates for those domains even before a matching + * resource exists. Requests that land on these routes before a real + * resource is created are served the placeholder page. TLS/cert-resolver + * handling differs between the OSS and private (pangolin-dns aware) config + * generators, so callers resolve that themselves via resolveTls - returning + * null skips the alias (no valid cert available yet). + */ +export function buildSiteResourceAliasCertPlaceholders(params: { + config_output: any; + siteResourcesWithFullDomain: SiteResourceAliasRow[]; + existingFullDomains: Set; + maintenancePageUiUrl: string | null; + redirectHttpsMiddlewareName: string; + resolveTls: (fullDomain: string) => any | null; +}): void { + const { + config_output, + siteResourcesWithFullDomain, + existingFullDomains, + maintenancePageUiUrl, + redirectHttpsMiddlewareName, + resolveTls + } = params; + + if (siteResourcesWithFullDomain.length === 0 || !maintenancePageUiUrl) { + return; + } + + for (const sr of siteResourcesWithFullDomain) { + if (!sr.fullDomain) continue; + + // Skip if this alias is already handled by a resource router + if (existingFullDomains.has(sr.fullDomain)) continue; + + const fullDomain = sr.fullDomain; + const srKey = `site-resource-cert-${sr.siteResourceId}`; + const siteResourceServiceName = `${srKey}-service`; + const siteResourceRouterName = `${srKey}-router`; + const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`; + + if (!config_output.http.routers) { + config_output.http.routers = {}; + } + if (!config_output.http.services) { + config_output.http.services = {}; + } + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + + // Service pointing at the internal maintenance/Next.js page + config_output.http.services[siteResourceServiceName] = { + loadBalancer: { + servers: [ + { + url: maintenancePageUiUrl + } + ], + passHostHeader: true + } + }; + + // Middleware that rewrites any path to /private-maintenance-screen + config_output.http.middlewares[siteResourceRewriteMiddlewareName] = { + replacePathRegex: { + regex: "^/(.*)", + replacement: "/private-maintenance-screen" + } + }; + + // HTTP -> HTTPS redirect so the ACME challenge can be served + config_output.http.routers[`${siteResourceRouterName}-redirect`] = { + entryPoints: [config.getRawConfig().traefik.http_entrypoint], + middlewares: [redirectHttpsMiddlewareName], + service: siteResourceServiceName, + rule: `Host(\`${fullDomain}\`)`, + priority: 100 + }; + + // Determine TLS / cert-resolver configuration + const tls = resolveTls(fullDomain); + if (tls === null) { + continue; + } + + // HTTPS router - presence of this entry triggers cert generation + config_output.http.routers[siteResourceRouterName] = { + entryPoints: [config.getRawConfig().traefik.https_entrypoint], + service: siteResourceServiceName, + middlewares: [siteResourceRewriteMiddlewareName], + rule: `Host(\`${fullDomain}\`)`, + priority: 100, + tls + }; + + // Assets bypass router - lets Next.js static files load without rewrite + config_output.http.routers[`${siteResourceRouterName}-assets`] = { + entryPoints: [config.getRawConfig().traefik.https_entrypoint], + service: siteResourceServiceName, + rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, + priority: 101, + tls + }; + } +} diff --git a/server/lib/traefik/types.ts b/server/lib/traefik/types.ts new file mode 100644 index 000000000..ab19ee653 --- /dev/null +++ b/server/lib/traefik/types.ts @@ -0,0 +1,21 @@ +import { Target } from "@server/db"; + +// Extended target type with site information, shared between the OSS and +// private getTraefikConfig implementations. +export type TargetWithSite = Target & { + resourceId: number; + targetId: number; + ip: string | null; + method: string | null; + port: number | null; + internalPort: number | null; + enabled: boolean; + health: string | null; + site: { + siteId: number; + type: string; + subnet: string | null; + exitNodeId: number | null; + online: boolean; + }; +}; diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index cd09add8a..5b309a4c4 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -38,3 +38,4 @@ export * from "./logActionAudit"; export * from "./verifyOlmAccess"; export * from "./verifyLimits"; export * from "./verifyResourcePolicyAccess"; +export * from "./verifyCertificateAccess"; diff --git a/server/private/middlewares/verifyCertificateAccess.ts b/server/middlewares/verifyCertificateAccess.ts similarity index 92% rename from server/private/middlewares/verifyCertificateAccess.ts rename to server/middlewares/verifyCertificateAccess.ts index 3d86db0ef..1d499939c 100644 --- a/server/private/middlewares/verifyCertificateAccess.ts +++ b/server/middlewares/verifyCertificateAccess.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 { db, domainNamespaces } from "@server/db"; import { certificates } from "@server/db"; diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts deleted file mode 100644 index 56105ac38..000000000 --- a/server/private/lib/acmeCertSync.ts +++ /dev/null @@ -1,888 +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 fs from "fs"; -import path from "path"; -import crypto from "crypto"; -import { - certificates, - clients, - clientSiteResourcesAssociationsCache, - db, - domains, - newts, - siteNetworks, - SiteResource, - siteResources -} from "@server/db"; -import { and, eq } from "drizzle-orm"; -import { encrypt, decrypt } from "@server/lib/crypto"; -import logger from "@server/logger"; -import privateConfig from "#private/lib/config"; -import config from "@server/lib/config"; -import { - generateSubnetProxyTargetV2, - SubnetProxyTargetV2 -} from "@server/lib/ip"; -import { updateTargets } from "@server/routers/client/targets"; -import cache from "#private/lib/cache"; -import { build } from "@server/build"; - -interface AcmeCert { - domain: { main: string; sans?: string[] }; - certificate: string; - key: string; - Store: string; -} - -interface AcmeJson { - [resolver: string]: { - Certificates: AcmeCert[]; - }; -} - -export async function pushCertUpdateToAffectedNewts( - domain: string, - domainId: string | null, - oldCertPem: string | null, - oldKeyPem: string | null -): Promise { - // Find all SSL-enabled HTTP site resources that use this cert's domain - let affectedResources: SiteResource[] = []; - - if (domainId) { - affectedResources = await db - .select() - .from(siteResources) - .where( - and( - eq(siteResources.domainId, domainId), - eq(siteResources.ssl, true) - ) - ); - } else { - // Fallback: match by exact fullDomain when no domainId is available - affectedResources = await db - .select() - .from(siteResources) - .where( - and( - eq(siteResources.fullDomain, domain), - eq(siteResources.ssl, true) - ) - ); - } - - if (affectedResources.length === 0) { - logger.debug( - `acmeCertSync: no affected site resources for cert domain "${domain}"` - ); - return; - } - - logger.debug( - `acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"` - ); - - for (const resource of affectedResources) { - try { - // Get all sites for this resource via siteNetworks - const resourceSiteRows = resource.networkId - ? await db - .select({ siteId: siteNetworks.siteId }) - .from(siteNetworks) - .where(eq(siteNetworks.networkId, resource.networkId)) - : []; - - if (resourceSiteRows.length === 0) { - logger.debug( - `acmeCertSync: no sites for resource ${resource.siteResourceId}, skipping` - ); - continue; - } - - // Get all clients with access to this resource - const resourceClients = await db - .select({ - clientId: clients.clientId, - pubKey: clients.pubKey, - subnet: clients.subnet - }) - .from(clients) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clients.clientId, - clientSiteResourcesAssociationsCache.clientId - ) - ) - .where( - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - resource.siteResourceId - ) - ); - - if (resourceClients.length === 0) { - logger.debug( - `acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping` - ); - continue; - } - - // Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data - if (resource.fullDomain) { - await cache.del(`cert:${resource.fullDomain}`); - } - - // Generate target once - same cert applies to all sites for this resource - const newTargets = await generateSubnetProxyTargetV2( - resource, - resourceClients - ); - - if (!newTargets) { - logger.debug( - `acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping` - ); - continue; - } - - // Construct the old targets - same routing shape but with the previous cert/key. - // The newt only uses destPrefix/sourcePrefixes for removal, but we keep the - // semantics correct so the update message accurately reflects what changed. - const oldTargets: SubnetProxyTargetV2[] = newTargets.map((t) => ({ - ...t, - tlsCert: oldCertPem ?? undefined, - tlsKey: oldKeyPem ?? undefined - })); - - // Push update to each site's newt - for (const { siteId } of resourceSiteRows) { - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, siteId)) - .limit(1); - - if (!newt) { - logger.debug( - `acmeCertSync: no newt found for site ${siteId}, skipping resource ${resource.siteResourceId}` - ); - continue; - } - - await updateTargets( - newt.newtId, - { oldTargets: oldTargets, newTargets: newTargets }, - newt.version - ); - - logger.debug( - `acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}` - ); - } - } catch (err) { - logger.error( - `acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}` - ); - } - } -} - -async function findDomainId(certDomain: string): Promise { - // Strip wildcard prefix before lookup (*.example.com -> example.com) - const lookupDomain = certDomain.startsWith("*.") - ? certDomain.slice(2) - : certDomain; - - // 1. Exact baseDomain match (any domain type) - const exactMatch = await db - .select({ domainId: domains.domainId }) - .from(domains) - .where(eq(domains.baseDomain, lookupDomain)) - .limit(1); - - if (exactMatch.length > 0) { - return exactMatch[0].domainId; - } - - // 2. Walk up the domain hierarchy looking for a wildcard-type domain whose - // baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com" - // matches a wildcard domain with baseDomain "example.com". - const parts = lookupDomain.split("."); - for (let i = 1; i < parts.length; i++) { - const candidate = parts.slice(i).join("."); - if (!candidate) continue; - - const wildcardMatch = await db - .select({ domainId: domains.domainId }) - .from(domains) - .where( - and( - eq(domains.baseDomain, candidate), - eq(domains.type, "wildcard") - ) - ) - .limit(1); - - if (wildcardMatch.length > 0) { - return wildcardMatch[0].domainId; - } - } - - return null; -} - -function extractFirstCert(pemBundle: string): string | null { - const match = pemBundle.match( - /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/ - ); - return match ? match[0] : null; -} - -/** - * Determine whether an ACME cert entry represents a wildcard cert by checking - * both the primary domain (`main`) and the SANs. Some ACME clients (notably - * Traefik) store the bare apex in `main` and only put the wildcard form in - * `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]). - */ -function detectWildcard( - main: string, - sans: string[] | undefined -): { wildcard: boolean; wildcardSan: string | null } { - if (main.startsWith("*.")) { - return { wildcard: true, wildcardSan: null }; - } - if (Array.isArray(sans)) { - for (const san of sans) { - if (typeof san !== "string") continue; - if (san === `*.${main}` || san.startsWith("*.")) { - return { wildcard: true, wildcardSan: san }; - } - } - } - return { wildcard: false, wildcardSan: null }; -} - -interface HttpCert { - wildcard: boolean; - altName: string; - certName: string; - commonName: string; - certFile: string; - keyFile: string; -} - -async function syncAcmeCertsFromHttp(endpoint: string): Promise { - let response: Response; - try { - response = await fetch(endpoint); - } catch (err) { - logger.debug( - `acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}` - ); - return; - } - - if (!response.ok) { - logger.debug( - `acmeCertSync: HTTP endpoint returned status ${response.status}` - ); - return; - } - - let httpCerts: HttpCert[]; - try { - httpCerts = await response.json(); - } catch (err) { - logger.debug( - `acmeCertSync: could not parse JSON from HTTP endpoint: ${err}` - ); - return; - } - - if (!Array.isArray(httpCerts) || httpCerts.length === 0) { - logger.debug( - `acmeCertSync: no certificates returned from HTTP endpoint` - ); - return; - } - - for (const cert of httpCerts) { - const domain = cert?.certName; - - if (!domain || typeof domain !== "string") { - logger.debug( - `acmeCertSync: skipping HTTP cert with missing certName` - ); - continue; - } - - const certPem = cert.certFile; - const keyPem = cert.keyFile; - - if (!certPem?.trim() || !keyPem?.trim()) { - logger.debug( - `acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile` - ); - continue; - } - - const firstCertPemForValidation = extractFirstCert(certPem); - if (!firstCertPemForValidation) { - logger.debug( - `acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found` - ); - continue; - } - - let validatedX509: crypto.X509Certificate; - try { - validatedX509 = new crypto.X509Certificate( - firstCertPemForValidation - ); - } catch (err) { - logger.debug( - `acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}` - ); - continue; - } - - try { - crypto.createPrivateKey(keyPem); - } catch (err) { - logger.debug( - `acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}` - ); - continue; - } - - const wildcard = cert.wildcard ?? false; - - const existing = await db - .select() - .from(certificates) - .where(eq(certificates.domain, domain)) - .limit(1); - - let oldCertPem: string | null = null; - let oldKeyPem: string | null = null; - - if (existing.length > 0 && existing[0].certFile) { - try { - const storedCertPem = decrypt( - existing[0].certFile, - config.getRawConfig().server.secret! - ); - const wildcardUnchanged = existing[0].wildcard === wildcard; - if (storedCertPem === certPem && wildcardUnchanged) { - continue; - } - oldCertPem = storedCertPem; - if (existing[0].keyFile) { - try { - oldKeyPem = decrypt( - existing[0].keyFile, - config.getRawConfig().server.secret! - ); - } catch (keyErr) { - logger.debug( - `acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}` - ); - } - } - } catch (err) { - logger.debug( - `acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}` - ); - } - } - - let expiresAt: number | null = null; - try { - expiresAt = Math.floor( - new Date(validatedX509.validTo).getTime() / 1000 - ); - } catch (err) { - logger.debug( - `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` - ); - } - - const encryptedCert = encrypt( - certPem, - config.getRawConfig().server.secret! - ); - const encryptedKey = encrypt( - keyPem, - config.getRawConfig().server.secret! - ); - const now = Math.floor(Date.now() / 1000); - - const domainId = await findDomainId(domain); - if (domainId) { - logger.debug( - `acmeCertSync: resolved domainId "${domainId}" for HTTP cert domain "${domain}"` - ); - } else { - logger.debug( - `acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"` - ); - } - - if (existing.length > 0) { - logger.debug( - `acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` - ); - await db - .update(certificates) - .set({ - certFile: encryptedCert, - keyFile: encryptedKey, - status: "valid", - expiresAt, - updatedAt: now, - wildcard, - ...(domainId !== null && { domainId }) - }) - .where(eq(certificates.domain, domain)); - - await pushCertUpdateToAffectedNewts( - domain, - domainId, - oldCertPem, - oldKeyPem - ); - } else { - logger.debug( - `acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` - ); - await db.insert(certificates).values({ - domain, - domainId, - certFile: encryptedCert, - keyFile: encryptedKey, - status: "valid", - expiresAt, - createdAt: now, - updatedAt: now, - wildcard - }); - - await pushCertUpdateToAffectedNewts(domain, domainId, null, null); - } - } -} - -async function storeCertForDomain( - domain: string, - certPem: string, - keyPem: string, - validatedX509: crypto.X509Certificate -): Promise { - const wildcard = domain.startsWith("*."); - - const existing = await db - .select() - .from(certificates) - .where(eq(certificates.domain, domain)) - .limit(1); - - let oldCertPem: string | null = null; - let oldKeyPem: string | null = null; - - if (existing.length > 0 && existing[0].certFile) { - try { - const storedCertPem = decrypt( - existing[0].certFile, - config.getRawConfig().server.secret! - ); - const wildcardUnchanged = existing[0].wildcard === wildcard; - if (storedCertPem === certPem && wildcardUnchanged) { - return; - } - oldCertPem = storedCertPem; - if (existing[0].keyFile) { - try { - oldKeyPem = decrypt( - existing[0].keyFile, - config.getRawConfig().server.secret! - ); - } catch (keyErr) { - logger.debug( - `acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}` - ); - } - } - } catch (err) { - logger.debug( - `acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}` - ); - } - } - - let expiresAt: number | null = null; - try { - expiresAt = Math.floor( - new Date(validatedX509.validTo).getTime() / 1000 - ); - } catch (err) { - logger.debug( - `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` - ); - } - - const encryptedCert = encrypt( - certPem, - config.getRawConfig().server.secret! - ); - const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!); - const now = Math.floor(Date.now() / 1000); - - const domainId = await findDomainId(domain); - if (domainId) { - logger.debug( - `acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"` - ); - } else { - logger.debug( - `acmeCertSync: no matching domain record found for cert domain "${domain}"` - ); - } - - if (existing.length > 0) { - logger.debug( - `acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` - ); - await db - .update(certificates) - .set({ - certFile: encryptedCert, - keyFile: encryptedKey, - status: "valid", - expiresAt, - updatedAt: now, - wildcard, - ...(domainId !== null && { domainId }) - }) - .where(eq(certificates.domain, domain)); - - logger.debug( - `acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` - ); - - await pushCertUpdateToAffectedNewts( - domain, - domainId, - oldCertPem, - oldKeyPem - ); - } else { - logger.debug( - `acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` - ); - await db.insert(certificates).values({ - domain, - domainId, - certFile: encryptedCert, - keyFile: encryptedKey, - status: "valid", - expiresAt, - createdAt: now, - updatedAt: now, - wildcard - }); - - logger.debug( - `acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` - ); - - await pushCertUpdateToAffectedNewts(domain, domainId, null, null); - } -} - -function findAcmeJsonFiles(dirPath: string): string[] { - const results: string[] = []; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dirPath, { withFileTypes: true }); - } catch (err) { - logger.warn( - `acmeCertSync: could not read directory "${dirPath}": ${err}` - ); - return results; - } - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - if (entry.isDirectory()) { - results.push(...findAcmeJsonFiles(fullPath)); - } else if (entry.isFile()) { - // check if it is a json file - if (entry.name.endsWith(".json")) { - let raw: string; - try { - raw = fs.readFileSync(fullPath, "utf8"); - } catch (err) { - logger.warn( - `acmeCertSync: could not read file "${fullPath}": ${err}` - ); - continue; - } - - let parsed: any; - try { - parsed = JSON.parse(raw); - } catch (err) { - logger.warn( - `acmeCertSync: could not parse "${fullPath}" as JSON: ${err}` - ); - continue; - } - } - - results.push(fullPath); - } - } - return results; -} - -async function syncAcmeCerts(acmeJsonPath: string): Promise { - let raw: string; - try { - raw = fs.readFileSync(acmeJsonPath, "utf8"); - } catch (err) { - logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`); - return; - } - - let acmeJson: AcmeJson; - try { - acmeJson = JSON.parse(raw); - } catch (err) { - logger.warn( - `acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}` - ); - return; - } - - const resolvers = Object.keys(acmeJson || {}); - if (resolvers.length === 0) { - logger.debug(`acmeCertSync: no resolvers found in acme.json`); - return; - } - - // Collect certificates from every resolver. If the same domain appears in - // multiple resolvers, the last one wins (resolvers iterated in object order). - const allCerts: AcmeCert[] = []; - for (const resolver of resolvers) { - const resolverData = acmeJson[resolver]; - if (!resolverData || !Array.isArray(resolverData.Certificates)) { - logger.debug( - `acmeCertSync: no certificates found for resolver "${resolver}"` - ); - continue; - } - // logger.debug( - // `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"` - // ); - for (const cert of resolverData.Certificates) { - allCerts.push(cert); - } - } - - for (const cert of allCerts) { - const mainDomain = cert?.domain?.main; - - if (!mainDomain || typeof mainDomain !== "string") { - logger.debug(`acmeCertSync: skipping cert with missing domain`); - continue; - } - - if (!cert.certificate || !cert.key) { - logger.debug( - `acmeCertSync: skipping cert for ${mainDomain} - empty certificate or key field` - ); - continue; - } - - let certPem: string; - let keyPem: string; - try { - certPem = Buffer.from(cert.certificate, "base64").toString("utf8"); - keyPem = Buffer.from(cert.key, "base64").toString("utf8"); - } catch (err) { - logger.debug( - `acmeCertSync: skipping cert for ${mainDomain} - failed to base64-decode cert/key: ${err}` - ); - continue; - } - - if (!certPem.trim() || !keyPem.trim()) { - logger.debug( - `acmeCertSync: skipping cert for ${mainDomain} - blank PEM after base64 decode` - ); - continue; - } - - // Validate that the decoded data actually parses as a real X.509 cert - // before we touch the database. This prevents importing partially-written - // or corrupted entries from acme.json. - const firstCertPemForValidation = extractFirstCert(certPem); - if (!firstCertPemForValidation) { - logger.debug( - `acmeCertSync: skipping cert for ${mainDomain} - no PEM certificate block found` - ); - continue; - } - - let validatedX509: crypto.X509Certificate; - try { - validatedX509 = new crypto.X509Certificate( - firstCertPemForValidation - ); - } catch (err) { - logger.debug( - `acmeCertSync: skipping cert for ${mainDomain} - invalid X.509 certificate: ${err}` - ); - continue; - } - - // Sanity-check the private key parses too - try { - crypto.createPrivateKey(keyPem); - } catch (err) { - logger.debug( - `acmeCertSync: skipping cert for ${mainDomain} - invalid private key: ${err}` - ); - continue; - } - - // Collect all domains covered by this cert: main + every SAN. - // Each domain gets its own row in the certificates table so that - // lookups by any hostname on the cert succeed independently. - const allDomains = new Set([mainDomain]); - if (Array.isArray(cert.domain?.sans)) { - for (const san of cert.domain.sans) { - if (typeof san === "string" && san.trim()) { - allDomains.add(san.trim()); - } - } - } - - // logger.debug( - // `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}` - // ); - - for (const domain of allDomains) { - try { - await storeCertForDomain( - domain, - certPem, - keyPem, - validatedX509 - ); - } catch (err) { - logger.error( - `acmeCertSync: error storing cert for domain "${domain}": ${err}` - ); - } - } - } -} - -export function initAcmeCertSync(): void { - if (build == "saas") { - logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`); - return; - } - - const privateConfigData = privateConfig.getRawPrivateConfig(); - - if (!privateConfigData.flags?.enable_acme_cert_sync) { - logger.debug( - `acmeCertSync: ACME cert sync is disabled by config flag, skipping` - ); - return; - } - - if (privateConfigData.flags.use_pangolin_dns) { - logger.debug( - `acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be disabled, skipping` - ); - return; - } - - const acmeJsonPath = - privateConfigData.acme?.acme_json_path ?? - "config/letsencrypt/acme.json"; - const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000; - const httpEndpoint = privateConfigData.acme?.acme_http_endpoint; - - logger.debug( - `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms` - ); - if (httpEndpoint) { - logger.debug( - `acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms` - ); - } - - const runSync = () => { - if (httpEndpoint) { - syncAcmeCertsFromHttp(httpEndpoint).catch((err) => { - logger.error(`acmeCertSync: error during HTTP sync: ${err}`); - }); - } else { - // only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up - let stat: fs.Stats | null = null; - try { - stat = fs.statSync(acmeJsonPath); - } catch (err) { - logger.warn( - `acmeCertSync: cannot stat path "${acmeJsonPath}": ${err}` - ); - return; - } - - if (stat.isDirectory()) { - const files = findAcmeJsonFiles(acmeJsonPath); - if (files.length === 0) { - logger.debug( - `acmeCertSync: no acme.json files found in directory "${acmeJsonPath}"` - ); - return; - } - // logger.debug( - // `acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"` - // ); - for (const file of files) { - syncAcmeCerts(file).catch((err) => { - logger.error( - `acmeCertSync: error during sync of "${file}": ${err}` - ); - }); - } - } else { - syncAcmeCerts(acmeJsonPath).catch((err) => { - logger.error(`acmeCertSync: error during sync: ${err}`); - }); - } - } - }; - - // Run immediately on init, then on the configured interval - runSync(); - - setInterval(runSync, intervalMs); -} diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts deleted file mode 100644 index 03ea6a58c..000000000 --- a/server/private/lib/certificates.ts +++ /dev/null @@ -1,240 +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 privateConfig from "./config"; -import config from "@server/lib/config"; -import { certificates, db } from "@server/db"; -import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; -import { decrypt } from "@server/lib/crypto"; -import logger from "@server/logger"; -import { regionalCache as cache } from "#private/lib/cache"; -import { build } from "@server/build"; - -// Define the return type for clarity and type safety -export type CertificateResult = { - id: number; - domain: string; - queriedDomain: string; // The domain that was originally requested (may differ for wildcards) - wildcard: boolean | null; - certFile: string | null; - keyFile: string | null; - expiresAt: number | null; - updatedAt?: number | null; -}; - -export async function getValidCertificatesForDomains( - domains: Set, - useCache: boolean = true -): Promise> { - const finalResults: CertificateResult[] = []; - const domainsToQuery = new Set(); - - // 1. Check cache first if enabled - if (useCache) { - for (const domain of domains) { - const cacheKey = `cert:${domain}`; - const cachedCert = await cache.get(cacheKey); - if (cachedCert) { - finalResults.push(cachedCert); // Valid cache hit - } else { - // Also check for a wildcard cache entry covering this domain's parent - const parts = domain.split("."); - let wildcardHit = false; - if (parts.length > 1) { - const parentDomain = parts.slice(1).join("."); - const wildcardCacheKey = `cert:*.${parentDomain}`; - const cachedWildcard = - await cache.get(wildcardCacheKey); - if (cachedWildcard) { - // Re-stamp queriedDomain so callers see the originally requested domain - finalResults.push({ - ...cachedWildcard, - queriedDomain: domain - }); - wildcardHit = true; - } - } - if (!wildcardHit) { - domainsToQuery.add(domain); // Cache miss or expired - } - } - } - } else { - // If caching is disabled, add all domains to the query set - domains.forEach((d) => domainsToQuery.add(d)); - } - - // 2. If all domains were resolved from the cache, return early - if (domainsToQuery.size === 0) { - const decryptedResults = decryptFinalResults( - finalResults, - config.getRawConfig().server.secret! - ); - return decryptedResults; - } - - // 3. Prepare domains for the database query - const domainsToQueryArray = Array.from(domainsToQuery); - const parentDomainsToQuery = new Set(); - - domainsToQueryArray.forEach((domain) => { - const parts = domain.split("."); - // A wildcard can only match a domain with at least two parts (e.g., example.com) - if (parts.length > 1) { - parentDomainsToQuery.add(parts.slice(1).join(".")); - } - }); - - const parentDomainsArray = Array.from(parentDomainsToQuery); - - // Build wildcard variants: for each parent domain "example.com", also query "*.example.com" - const wildcardPrefixedArray = - build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : []; - - // 4. Build and execute a single, efficient Drizzle query - // This query fetches all potential exact and wildcard matches in one database round-trip. - const potentialCerts = await db - .select() - .from(certificates) - .where( - and( - eq(certificates.status, "valid"), - isNotNull(certificates.certFile), - isNotNull(certificates.keyFile), - or( - // Condition for exact matches on the requested domains - inArray(certificates.domain, domainsToQueryArray), - // Condition for wildcard matches on the parent domains (stored as "example.com" or "*.example.com") - parentDomainsArray.length > 0 - ? and( - inArray(certificates.domain, [ - ...parentDomainsArray, - ...wildcardPrefixedArray - ]), - eq(certificates.wildcard, true) - ) - : // If there are no possible parent domains, this condition is false - sql`false` - ) - ) - ); - - // Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.") - const normalizeWildcardDomain = (domain: string): string => - domain.startsWith("*.") ? domain.slice(2) : domain; - - // 5. Process the database results, prioritizing exact matches over wildcards - const exactMatches = new Map(); - const wildcardMatches = new Map(); - - for (const cert of potentialCerts) { - if (cert.wildcard) { - // Normalize to bare parent domain so lookups are consistent regardless of storage format - wildcardMatches.set(normalizeWildcardDomain(cert.domain), cert); - } else { - exactMatches.set(cert.domain, cert); - } - } - - for (const domain of domainsToQuery) { - let foundCert: (typeof potentialCerts)[0] | undefined = undefined; - - // Priority 1: Check for an exact match (non-wildcard) - if (exactMatches.has(domain)) { - foundCert = exactMatches.get(domain); - } - // Priority 2: Check for a wildcard certificate whose normalized domain equals the queried domain - else { - const normalizedDomain = normalizeWildcardDomain(domain); - if (wildcardMatches.has(normalizedDomain)) { - foundCert = wildcardMatches.get(normalizedDomain); - } - // Priority 3: Check for a wildcard match on the parent domain - else { - const parts = normalizedDomain.split("."); - if (parts.length > 1) { - const parentDomain = parts.slice(1).join("."); - if (wildcardMatches.has(parentDomain)) { - foundCert = wildcardMatches.get(parentDomain); - } - } - } - } - - // If a certificate was found, format it, add to results, and cache it - if (foundCert) { - logger.debug( - `Creating result cert for ${domain} using cert from ${foundCert.domain}` - ); - const resultCert: CertificateResult = { - id: foundCert.certId, - domain: foundCert.domain, // The actual domain of the cert record - queriedDomain: domain, // The domain that was originally requested - wildcard: foundCert.wildcard, - certFile: foundCert.certFile, - keyFile: foundCert.keyFile, - expiresAt: foundCert.expiresAt, - updatedAt: foundCert.updatedAt - }; - - finalResults.push(resultCert); - - // Add to cache for future requests, using the *requested domain* as the key - if (useCache) { - const cacheKey = `cert:${domain}`; - await cache.set(cacheKey, resultCert, 180); - - // Also cache wildcard certs under a pattern key so other subdomains - // can find them without a DB round-trip - if (resultCert.wildcard) { - const normalizedCertDomain = normalizeWildcardDomain( - resultCert.domain - ); - const wildcardCacheKey = `cert:*.${normalizedCertDomain}`; - await cache.set(wildcardCacheKey, resultCert, 180); - } - } - } - } - - const decryptedResults = decryptFinalResults( - finalResults, - config.getRawConfig().server.secret! - ); - return decryptedResults; -} - -function decryptFinalResults( - finalResults: CertificateResult[], - secret: string -): CertificateResult[] { - const validCertsDecrypted = finalResults.map((cert) => { - // Decrypt and save certificate file - const decryptedCert = decrypt( - cert.certFile!, // is not null from query - secret - ); - - // Decrypt and save key file - const decryptedKey = decrypt(cert.keyFile!, secret); - - // Return only the certificate data without org information - return { - ...cert, - certFile: decryptedCert, - keyFile: decryptedKey - }; - }); - - return validCertsDecrypted; -} diff --git a/server/private/lib/config.ts b/server/private/lib/config.ts index 75600fba6..278d9d636 100644 --- a/server/private/lib/config.ts +++ b/server/private/lib/config.ts @@ -19,6 +19,9 @@ import { privateConfigSchema, readPrivateConfigFile } from "#private/lib/readConfigFile"; +import config from "@server/lib/config"; +import { readConfigFile as readPublicConfigFile } from "@server/lib/readConfigFile"; +import logger from "@server/logger"; export class PrivateConfig { private rawPrivateConfig!: z.infer; @@ -45,6 +48,8 @@ export class PrivateConfig { this.rawPrivateConfig = parsedPrivateConfig; + this.migrateDeprecatedAcmeConfig(privateEnvironment); + process.env.BRANDING_HIDE_AUTH_LAYOUT_FOOTER = this.rawPrivateConfig.branding?.hide_auth_layout_footer === true ? "true" @@ -146,6 +151,38 @@ export class PrivateConfig { public getRawPrivateConfig() { return this.rawPrivateConfig; } + + // `flags.enable_acme_cert_sync` and `acme` used to live in the private + // config file. They now live in the public config file. If an operator + // still has them set in the private config and hasn't moved them over to + // the public config, pull them forward so behavior doesn't silently + // change out from under them. + private migrateDeprecatedAcmeConfig(privateEnvironment: any) { + const publicEnvironment: any = readPublicConfigFile(); + const rawConfig: any = config.getRawConfig(); + + if ( + privateEnvironment?.flags?.enable_acme_cert_sync !== undefined && + publicEnvironment?.flags?.enable_acme_cert_sync === undefined + ) { + logger.warn( + "`flags.enable_acme_cert_sync` is deprecated in the private config file and has moved to the public config file. Using the value from the private config file for now, but please move it to the public config." + ); + rawConfig.flags = rawConfig.flags ?? {}; + rawConfig.flags.enable_acme_cert_sync = + this.rawPrivateConfig.flags.enable_acme_cert_sync; + } + + if ( + privateEnvironment?.acme !== undefined && + publicEnvironment?.acme === undefined + ) { + logger.warn( + "`acme` is deprecated in the private config file and has moved to the public config file. Using the value from the private config file for now, but please move it to the public config." + ); + rawConfig.acme = this.rawPrivateConfig.acme; + } + } } export const privateConfig = new PrivateConfig(); diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 565a0151a..e0ee9a821 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -109,6 +109,11 @@ export const privateConfigSchema = z enable_redis: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false), use_org_only_idp: z.boolean().optional(), + // @deprecated Moved to the public config file as + // `flags.enable_acme_cert_sync` (server/lib/readConfigFile.ts). + // Kept here only so existing private config files keep parsing; + // any value set here is migrated into the public config at + // startup by PrivateConfig (server/private/lib/config.ts). enable_acme_cert_sync: z.boolean().optional().default(true), disable_private_http_placeholder: z .boolean() @@ -117,6 +122,10 @@ export const privateConfigSchema = z }) .optional() .prefault({}), + // @deprecated Moved to the public config file as `acme` + // (server/lib/readConfigFile.ts). Kept here only so existing private + // config files keep parsing; any value set here is migrated into the + // public config at startup by PrivateConfig (server/private/lib/config.ts). acme: z .object({ acme_json_path: z diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index bea78fa3e..354a976b2 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -40,7 +40,6 @@ import { sites, siteNetworks, siteResources, - Target, targets } from "@server/db"; import { @@ -49,44 +48,47 @@ import { validatePathRewriteConfig } from "@server/lib/traefik/utils"; import privateConfig from "#private/lib/config"; -import createPathRewriteMiddleware from "@server/lib/traefik/middleware"; +import { applyPathRewriteMiddleware } from "@server/lib/traefik/middleware"; import { CertificateResult, getValidCertificatesForDomains -} from "#private/lib/certificates"; +} from "@server/lib/certificates"; import { build } from "@server/build"; import regionalCache from "#private/lib/cache"; +import { TargetWithSite } from "@server/lib/traefik/types"; +import { buildWildcardTls } from "@server/lib/traefik/certResolver"; import { - AI_GATEWAY_TRUST_HEADER, - AI_GATEWAY_RESOURCE_TYPE_HEADER, - AI_GATEWAY_CLIENT_IP_HEADER, - getAiGatewayTrustToken -} from "@server/lib/aiGatewayTrust"; + buildHostRule, + appendPathMatch, + computeRoutePriority +} from "@server/lib/traefik/rule"; +import { + buildHttpLoadBalancerServers, + buildStickySessionCookie, + buildTcpUdpLoadBalancerServers, + buildStickySessionIp +} from "@server/lib/traefik/loadBalancer"; +import { buildCustomHeadersMiddleware } from "@server/lib/traefik/headersMiddleware"; +import { + AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE, + AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE, + AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME, + getAiGatewayHost, + buildAiGatewayTrustMiddlewares, + buildAiGatewayClientIpMiddleware, + buildAiGatewayHostHeaderMiddleware, + buildAiGatewayRouterAndService +} from "@server/lib/traefik/aiGatewayMiddlewares"; +import { + buildBrowserGatewayResourcesMap, + buildBrowserGatewayConfig +} from "@server/lib/traefik/browserGateway"; +import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias"; const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectToRootMiddlewareName = "redirect-to-root"; const badgerMiddlewareName = "badger"; const landingRateLimitMiddlewareName = "landing-ratelimit"; -const bgRateLimitMiddlewareName = "bg-ratelimit"; - -// Define extended target type with site information -type TargetWithSite = Target & { - resourceId: number; - targetId: number; - ip: string | null; - method: string | null; - port: number | null; - internalPort: number | null; - enabled: boolean; - health: string | null; - site: { - siteId: number; - type: string; - subnet: string | null; - exitNodeId: number | null; - online: boolean; - }; -}; export async function getTraefikConfig( exitNodeId: number, @@ -313,74 +315,12 @@ export async function getTraefikConfig( } // Group browser gateway targets by resource - type BrowserGatewayResourceEntry = { - resourceId: number; - name: string; - fullDomain: string | null; - ssl: boolean | null; - subdomain: string | null; - domainId: string | null; - enabled: boolean | null; - wildcard: boolean | null; - domainCertResolver: string | null; - preferWildcardCert: boolean | null; - maintenanceModeEnabled: boolean | null; - maintenanceModeType: string | null; - maintenanceTitle: string | null; - maintenanceMessage: string | null; - maintenanceEstimatedTime: string | null; - targets: { - targetId: number; - bgType: string; - siteId: number; - siteType: string; - siteOnline: boolean | null; - subnet: string | null; - }[]; - }; - const browserGatewayResourcesMap = new Map< - number, - BrowserGatewayResourceEntry - >(); - - if (browserGatewayUiUrl) { - for (const row of resourcesWithTargetsAndSites) { - if (!["ssh", "vnc", "rdp"].includes(row.mode)) { - continue; - } - if (filterOutNamespaceDomains && row.domainNamespaceId) { - continue; - } - if (!browserGatewayResourcesMap.has(row.resourceId)) { - browserGatewayResourcesMap.set(row.resourceId, { - resourceId: row.resourceId, - name: sanitize(row.resourceName) || "", - fullDomain: row.fullDomain, - ssl: row.ssl, - subdomain: row.subdomain, - domainId: row.domainId, - enabled: row.enabled, - wildcard: row.wildcard, - domainCertResolver: row.domainCertResolver, - preferWildcardCert: row.preferWildcardCert, - maintenanceModeEnabled: row.maintenanceModeEnabled, - maintenanceModeType: row.maintenanceModeType, - maintenanceTitle: row.maintenanceTitle, - maintenanceMessage: row.maintenanceMessage, - maintenanceEstimatedTime: row.maintenanceEstimatedTime, - targets: [] - }); - } - browserGatewayResourcesMap.get(row.resourceId)!.targets.push({ - targetId: row.targetId, - bgType: row.mode, - siteId: row.siteId, - siteType: row.siteType, - siteOnline: row.siteOnline, - subnet: row.subnet - }); - } - } + const browserGatewayResourcesMap = browserGatewayUiUrl + ? buildBrowserGatewayResourcesMap( + resourcesWithTargetsAndSites, + filterOutNamespaceDomains + ) + : new Map(); let siteResourcesWithFullDomain: { siteResourceId: number; @@ -515,12 +455,6 @@ export async function getTraefikConfig( average: traefikRateLimit.average, burst: traefikRateLimit.burst } - }, - [bgRateLimitMiddlewareName]: { - rateLimit: { - average: traefikRateLimit.average, - burst: traefikRateLimit.burst - } } } } @@ -580,91 +514,23 @@ export async function getTraefikConfig( ...additionalMiddlewares ]; - let rule: string; - if (resource.wildcard && fullDomain.startsWith("*.")) { - // Convert *.foo.bar.com -> HostRegexp(`^[^.]+\.foo\.bar\.com$`) - const escaped = fullDomain - .slice(2) // remove leading "*." - .replace(/\./g, "\\."); - rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`; - } else { - rule = `Host(\`${fullDomain}\`)`; - } + let rule: string = buildHostRule(fullDomain, resource.wildcard); - // priority logic - let priority: number; - if (resource.priority && resource.priority != 100) { - priority = resource.priority; - } else { - priority = 100; - if (resource.path && resource.pathMatchType) { - priority += 10; - if (resource.pathMatchType === "exact") { - priority += 5; - } else if (resource.pathMatchType === "prefix") { - priority += 3; - } else if (resource.pathMatchType === "regex") { - priority += 2; - } - if (resource.path === "/") { - priority = 1; // lowest for catch-all - } - } - } + const priority = computeRoutePriority( + resource.priority, + resource.path, + resource.pathMatchType + ); let tls = {}; if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { - const domainParts = fullDomain.split("."); - let wildCard; - if (domainParts.length <= 2) { - wildCard = `*.${domainParts.join(".")}`; - } else { - wildCard = `*.${domainParts.slice(1).join(".")}`; - } - - if (!resource.subdomain) { - wildCard = resource.fullDomain; - } - - const globalDefaultResolver = - config.getRawConfig().traefik.cert_resolver; - const globalDefaultPreferWildcard = - config.getRawConfig().traefik.prefer_wildcard_cert; - - const domainCertResolver = resource.domainCertResolver; - const preferWildcardCert = - resource.preferWildcardCert || resource.wildcard; - - let resolverName: string | undefined; - let preferWildcard: boolean | undefined; - // Handle both letsencrypt & custom cases - if (domainCertResolver) { - resolverName = domainCertResolver.trim(); - } else { - resolverName = globalDefaultResolver; - } - - if ( - preferWildcardCert !== undefined && - preferWildcardCert !== null - ) { - preferWildcard = preferWildcardCert; - } else { - preferWildcard = globalDefaultPreferWildcard; - } - - tls = { - certResolver: resolverName, - ...(preferWildcard - ? { - domains: [ - { - main: wildCard - } - ] - } - : {}) - }; + tls = buildWildcardTls({ + fullDomain, + hasSubdomain: !!resource.subdomain, + domainCertResolver: resource.domainCertResolver, + preferWildcardCert: + resource.preferWildcardCert || resource.wildcard + }); } else { // find a cert that matches the full domain, if not continue const matchingCert = validCerts.find( @@ -803,111 +669,32 @@ export async function getTraefikConfig( } // Handle path rewriting middleware - if ( - resource.rewritePath !== null && - resource.path !== null && - resource.pathMatchType && - resource.rewritePathType - ) { - // Create a unique middleware name - const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${key}`; + applyPathRewriteMiddleware( + config_output, + resource.resourceId, + key, + resource.path, + resource.pathMatchType, + resource.rewritePath, + resource.rewritePathType, + routerMiddlewares + ); - try { - const rewriteResult = createPathRewriteMiddleware( - rewriteMiddlewareName, - resource.path, - resource.pathMatchType, - resource.rewritePath, - resource.rewritePathType - ); - - // Initialize middlewares object if it doesn't exist - if (!config_output.http.middlewares) { - config_output.http.middlewares = {}; - } - - // the middleware to the config - Object.assign( - config_output.http.middlewares, - rewriteResult.middlewares - ); - - // middlewares to the router middleware chain - if (rewriteResult.chain) { - // For chained middlewares (like stripPrefix + addPrefix) - routerMiddlewares.push(...rewriteResult.chain); - } else { - // Single middleware - routerMiddlewares.push(rewriteMiddlewareName); - } - - // logger.debug( - // `Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})` - // ); - } catch (error) { - logger.error( - `Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}` - ); + const customHeadersMiddleware = buildCustomHeadersMiddleware( + resource.headers, + resource.setHostHeader, + resource.resourceId + ); + if (customHeadersMiddleware) { + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; } + config_output.http.middlewares[headersMiddlewareName] = + customHeadersMiddleware; + routerMiddlewares.push(headersMiddlewareName); } - if (resource.headers || resource.setHostHeader) { - // if there are headers, parse them into an object - const headersObj: { [key: string]: string } = {}; - if (resource.headers) { - let headersArr: { name: string; value: string }[] = []; - try { - headersArr = JSON.parse(resource.headers) as { - name: string; - value: string; - }[]; - } catch (e) { - logger.warn( - `Failed to parse headers for resource ${resource.resourceId}: ${e}` - ); - } - - headersArr.forEach((header) => { - headersObj[header.name] = header.value; - }); - } - - if (resource.setHostHeader) { - headersObj["Host"] = resource.setHostHeader; - } - - // check if the object is not empty - if (Object.keys(headersObj).length > 0) { - // Add the headers middleware - if (!config_output.http.middlewares) { - config_output.http.middlewares = {}; - } - config_output.http.middlewares[headersMiddlewareName] = { - headers: { - customRequestHeaders: headersObj - } - }; - - routerMiddlewares.push(headersMiddlewareName); - } - } - - if (resource.path && resource.pathMatchType) { - //priority += 1; - // add path to rule based on match type - let path = resource.path; - // if the path doesn't start with a /, add it - if (!path.startsWith("/")) { - path = `/${path}`; - } - if (resource.pathMatchType === "exact") { - rule += ` && Path(\`${path}\`)`; - } else if (resource.pathMatchType === "prefix") { - rule += ` && PathPrefix(\`${path}\`)`; - } else if (resource.pathMatchType === "regex") { - rule += ` && PathRegexp(\`${resource.path}\`)`; // this is the raw path because it's a regex - } - } + rule = appendPathMatch(rule, resource.path, resource.pathMatchType); config_output.http.routers![routerName] = { entryPoints: [ @@ -924,90 +711,9 @@ export async function getTraefikConfig( config_output.http.services![serviceName] = { loadBalancer: { - servers: (() => { - // Check if any sites are online - // THIS IS SO THAT THERE IS SOME IMMEDIATE FEEDBACK - // EVEN IF THE SITES HAVE NOT UPDATED YET FROM THE - // RECEIVE BANDWIDTH ENDPOINT. - - // TODO: HOW TO HANDLE ^^^^^^ BETTER - const anySitesOnline = targets.some( - (target) => target.site.online - ); - - return ( - targets - .filter((target) => { - if (!target.enabled) { - return false; - } - - if (target.health == "unhealthy") { - return false; - } - - // If any sites are online, exclude offline sites - if (anySitesOnline && !target.site.online) { - return false; - } - - if ( - target.site.type === "local" || - target.site.type === "wireguard" - ) { - if ( - !target.ip || - !target.port || - !target.method - ) { - return false; - } - } else if (target.site.type === "newt") { - if ( - !target.internalPort || - !target.method || - !target.site.subnet - ) { - return false; - } - } - return true; - }) - .map((target) => { - if ( - target.site.type === "local" || - target.site.type === "wireguard" - ) { - return { - url: `${target.method}://${target.ip}:${target.port}` - }; - } else if (target.site.type === "newt") { - const ip = - target.site.subnet!.split("/")[0]; - return { - url: `${target.method}://${ip}:${target.internalPort}` - }; - } - }) - // filter out duplicates - .filter( - (v, i, a) => - a.findIndex( - (t) => t && v && t.url === v.url - ) === i - ) - ); - })(), + servers: buildHttpLoadBalancerServers(targets), ...(resource.stickySession - ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } + ? buildStickySessionCookie(resource.ssl) : {}) } }; @@ -1057,131 +763,42 @@ export async function getTraefikConfig( config_output[protocol].services[serviceName] = { loadBalancer: { - servers: (() => { - // Check if any sites are online - const anySitesOnline = targets.some( - (target) => target.site.online - ); - - return targets - .filter((target) => { - if (!target.enabled) { - return false; - } - - // If any sites are online, exclude offline sites - if (anySitesOnline && !target.site.online) { - return false; - } - - if ( - target.site.type === "local" || - target.site.type === "wireguard" - ) { - if (!target.ip || !target.port) { - return false; - } - } else if (target.site.type === "newt") { - if ( - !target.internalPort || - !target.site.subnet - ) { - return false; - } - } - return true; - }) - .map((target) => { - if ( - target.site.type === "local" || - target.site.type === "wireguard" - ) { - return { - address: `${target.ip}:${target.port}` - }; - } else if (target.site.type === "newt") { - const ip = - target.site.subnet!.split("/")[0]; - return { - address: `${ip}:${target.internalPort}` - }; - } - }); - })(), + servers: buildTcpUdpLoadBalancerServers(targets), ...(resource.proxyProtocol && protocol == "tcp" // proxy protocol only works for tcp ? { serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues? } : {}), - ...(resource.stickySession - ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } - : {}) + ...(resource.stickySession ? buildStickySessionIp() : {}) } }; } } if (browserGatewayUiUrl) { - // Generate Traefik config for browser gateway resources - const browserGatewayPort = 39999; - for (const [, bgResource] of browserGatewayResourcesMap.entries()) { - if (!bgResource.enabled) continue; - if (!bgResource.domainId) continue; - if (!bgResource.fullDomain) continue; - - if (!config_output.http.routers) config_output.http.routers = {}; - if (!config_output.http.services) config_output.http.services = {}; - - const fullDomain = bgResource.fullDomain; - const additionalMiddlewares = - config.getRawConfig().traefik.additional_middlewares || []; - const routerMiddlewares = [ - badgerMiddlewareName, - bgRateLimitMiddlewareName, - ...additionalMiddlewares - ]; - - const hostRule = `Host(\`${fullDomain}\`)`; - - // Build TLS config - let tls = {}; - if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { - const domainParts = fullDomain.split("."); - let wildCard: string; - if (domainParts.length <= 2) { - wildCard = `*.${domainParts.join(".")}`; - } else { - wildCard = `*.${domainParts.slice(1).join(".")}`; + buildBrowserGatewayConfig({ + config_output, + browserGatewayResourcesMap, + browserGatewayUiUrl, + maintenancePageUiUrl, + badgerMiddlewareName, + redirectHttpsMiddlewareName, + resolveTls: ({ + fullDomain, + hasSubdomain, + domainCertResolver, + preferWildcardCert + }) => { + if ( + !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns + ) { + return buildWildcardTls({ + fullDomain, + hasSubdomain, + domainCertResolver, + preferWildcardCert + }); } - if (!bgResource.subdomain) { - wildCard = fullDomain; - } - - const globalDefaultResolver = - config.getRawConfig().traefik.cert_resolver; - const globalDefaultPreferWildcard = - config.getRawConfig().traefik.prefer_wildcard_cert; - const resolverName = bgResource.domainCertResolver - ? bgResource.domainCertResolver.trim() - : globalDefaultResolver; - const preferWildcard = - bgResource.preferWildcardCert !== undefined && - bgResource.preferWildcardCert !== null - ? bgResource.preferWildcardCert - : globalDefaultPreferWildcard; - - tls = { - certResolver: resolverName, - ...(preferWildcard ? { domains: [{ main: wildCard }] } : {}) - }; - } else { const matchingCert = validCerts.find( (cert) => cert.queriedDomain === fullDomain ); @@ -1189,231 +806,11 @@ export async function getTraefikConfig( logger.debug( `No matching certificate found for browser gateway domain: ${fullDomain}` ); - continue; + return null; } + return {}; } - - const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`; - - if (bgResource.ssl) { - const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`; - config_output.http.routers![redirectRouterName] = { - entryPoints: [ - config.getRawConfig().traefik.http_entrypoint - ], - middlewares: [redirectHttpsMiddlewareName], - service: bgUiServiceName, - rule: hostRule, - priority: 100 - }; - } - - // Collect online sites for this resource (for any type) - const anySiteOnline = bgResource.targets.some((t) => t.siteOnline); - - // Maintenance page logic for browser gateway resources - let showBgMaintenancePage = false; - if (bgResource.maintenanceModeEnabled) { - if (bgResource.maintenanceModeType === "forced") { - showBgMaintenancePage = true; - } else if (bgResource.maintenanceModeType === "automatic") { - showBgMaintenancePage = !anySiteOnline; - } - } - - if (showBgMaintenancePage && maintenancePageUiUrl) { - const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`; - const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`; - const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`; - const bgMaintenanceHeadersMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-headers`; - - const entrypointHttp = - config.getRawConfig().traefik.http_entrypoint; - const entrypointHttps = - config.getRawConfig().traefik.https_entrypoint; - - if (!config_output.http.services) - config_output.http.services = {}; - if (!config_output.http.middlewares) - config_output.http.middlewares = {}; - if (!config_output.http.routers) - config_output.http.routers = {}; - - config_output.http.services![bgMaintenanceServiceName] = { - loadBalancer: { - servers: [ - { - url: maintenancePageUiUrl - } - ], - passHostHeader: true - } - }; - - config_output.http.middlewares![bgRewriteMiddlewareName] = { - replacePathRegex: { - regex: "^/(.*)", - replacement: "/maintenance-screen" - } - }; - - config_output.http.middlewares![ - bgMaintenanceHeadersMiddlewareName - ] = { - headers: { - customRequestHeaders: { - Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource - "p-host": fullDomain - } - } - }; - - config_output.http.routers![bgMaintenanceRouterName] = { - entryPoints: [ - bgResource.ssl ? entrypointHttps : entrypointHttp - ], - service: bgMaintenanceServiceName, - middlewares: [ - bgRewriteMiddlewareName, - bgMaintenanceHeadersMiddlewareName - ], - rule: hostRule, - priority: 2000, - ...(bgResource.ssl ? { tls } : {}) - }; - - config_output.http.routers![ - `${bgMaintenanceRouterName}-assets` - ] = { - entryPoints: [ - bgResource.ssl ? entrypointHttps : entrypointHttp - ], - service: bgMaintenanceServiceName, - middlewares: [bgMaintenanceHeadersMiddlewareName], - rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, - priority: 2001, - ...(bgResource.ssl ? { tls } : {}) - }; - - continue; - } - - // Group targets by type and generate per-type websocket routers and services - const typeMap = new Map(); - for (const t of bgResource.targets) { - if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []); - typeMap.get(t.bgType)!.push(t); - } - - for (const [bgType, typedTargets] of typeMap.entries()) { - const bgKey = `bg-r${bgResource.resourceId}-${bgType}`; - const bgRouterName = `${bgKey}-router`; - const bgServiceName = `${bgKey}-service`; - const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`; - - const servers = typedTargets - .filter((t) => { - if (!t.siteOnline && anySiteOnline) return false; - if (t.siteType === "newt") return !!t.subnet; - return false; // browser gateway only supported on newt sites - }) - .map((t) => ({ - url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}` - })) - .filter( - (v, i, a) => a.findIndex((u) => u.url === v.url) === i - ); - - config_output.http.routers![bgRouterName] = { - entryPoints: [ - bgResource.ssl - ? config.getRawConfig().traefik.https_entrypoint - : config.getRawConfig().traefik.http_entrypoint - ], - middlewares: routerMiddlewares, - service: bgServiceName, - rule: bgRule, - priority: 110, // highest - websocket path takes precedence - ...(bgResource.ssl ? { tls } : {}) - }; - - config_output.http.services![bgServiceName] = { - loadBalancer: { - servers - } - }; - } - - // UI: serve the browser gateway page from the internal pangolin instance. - // The primary type is used for the path rewrite (e.g. /rdp), mirroring - // how the maintenance page rewrites everything to /maintenance-screen. - const primaryType = typeMap.keys().next().value as string; - const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`; - const uiHeadersMiddlewareName = `bg-r${bgResource.resourceId}-ui-headers`; - const entrypoint = bgResource.ssl - ? config.getRawConfig().traefik.https_entrypoint - : config.getRawConfig().traefik.http_entrypoint; - - if (!config_output.http.middlewares) { - config_output.http.middlewares = {}; - } - - config_output.http.middlewares![uiRewriteMiddlewareName] = { - replacePathRegex: { - regex: "^/(.*)", - replacement: `/${primaryType}` - } - }; - - config_output.http.middlewares![uiHeadersMiddlewareName] = { - headers: { - customRequestHeaders: { - Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource - "p-host": fullDomain - } - } - }; - - config_output.http.services![bgUiServiceName] = { - loadBalancer: { - servers: [ - { - url: browserGatewayUiUrl - } - ] - } - }; - - // Assets router at higher priority so /_next files load without rewrite. - // Do NOT apply the path-rewrite middleware here — static assets must - // keep their original path; only the host headers are needed. - config_output.http.routers![ - `bg-r${bgResource.resourceId}-assets-router` - ] = { - entryPoints: [entrypoint], - middlewares: [...routerMiddlewares, uiHeadersMiddlewareName], - service: bgUiServiceName, - rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, - priority: 101, - ...(bgResource.ssl ? { tls } : {}) - }; - - // Catch-all router rewrites everything on the domain to /{primaryType} - config_output.http.routers![ - `bg-r${bgResource.resourceId}-ui-router` - ] = { - entryPoints: [entrypoint], - middlewares: [ - ...routerMiddlewares, - uiRewriteMiddlewareName, - uiHeadersMiddlewareName - ], - service: bgUiServiceName, - rule: hostRule, - priority: 100, - ...(bgResource.ssl ? { tls } : {}) - }; - } + }); } // Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that @@ -1428,79 +825,24 @@ export async function getTraefikConfig( } } - for (const sr of siteResourcesWithFullDomain) { - if (!sr.fullDomain) continue; - - // Skip if this alias is already handled by a resource router - if (existingFullDomains.has(sr.fullDomain)) continue; - - const fullDomain = sr.fullDomain; - const srKey = `site-resource-cert-${sr.siteResourceId}`; - const siteResourceServiceName = `${srKey}-service`; - const siteResourceRouterName = `${srKey}-router`; - const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`; - - if (!config_output.http.routers) { - config_output.http.routers = {}; - } - if (!config_output.http.services) { - config_output.http.services = {}; - } - if (!config_output.http.middlewares) { - config_output.http.middlewares = {}; - } - - // Service pointing at the internal maintenance/Next.js page - config_output.http.services[siteResourceServiceName] = { - loadBalancer: { - servers: [ - { - url: maintenancePageUiUrl - } - ], - passHostHeader: true + buildSiteResourceAliasCertPlaceholders({ + config_output, + siteResourcesWithFullDomain, + existingFullDomains, + maintenancePageUiUrl, + redirectHttpsMiddlewareName, + resolveTls: (fullDomain) => { + if ( + !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns + ) { + // siteResource aliases don't have a per-domain cert + // resolver stored, so always fall back to the global + // defaults. + return buildWildcardTls({ + fullDomain, + hasSubdomain: true + }); } - }; - - // Middleware that rewrites any path to /maintenance-screen - config_output.http.middlewares[siteResourceRewriteMiddlewareName] = - { - replacePathRegex: { - regex: "^/(.*)", - replacement: "/private-maintenance-screen" - } - }; - - // HTTP -> HTTPS redirect so the ACME challenge can be served - config_output.http.routers[`${siteResourceRouterName}-redirect`] = { - entryPoints: [config.getRawConfig().traefik.http_entrypoint], - middlewares: [redirectHttpsMiddlewareName], - service: siteResourceServiceName, - rule: `Host(\`${fullDomain}\`)`, - priority: 100 - }; - - // Determine TLS / cert-resolver configuration - let tls: any = {}; - if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { - const domainParts = fullDomain.split("."); - const wildCard = - domainParts.length <= 2 - ? `*.${domainParts.join(".")}` - : `*.${domainParts.slice(1).join(".")}`; - - const globalDefaultResolver = - config.getRawConfig().traefik.cert_resolver; - const globalDefaultPreferWildcard = - config.getRawConfig().traefik.prefer_wildcard_cert; - - tls = { - certResolver: globalDefaultResolver, - ...(globalDefaultPreferWildcard - ? { domains: [{ main: wildCard }] } - : {}) - }; - } else { // pangolin-dns: only add route if we already have a valid cert const matchingCert = validCerts.find( (cert) => cert.queriedDomain === fullDomain @@ -1509,29 +851,11 @@ export async function getTraefikConfig( logger.debug( `No matching certificate found for siteResource alias: ${fullDomain}` ); - continue; + return null; } + return {}; } - - // HTTPS router - presence of this entry triggers cert generation - config_output.http.routers[siteResourceRouterName] = { - entryPoints: [config.getRawConfig().traefik.https_entrypoint], - service: siteResourceServiceName, - middlewares: [siteResourceRewriteMiddlewareName], - rule: `Host(\`${fullDomain}\`)`, - priority: 100, - tls - }; - - // Assets bypass router - lets Next.js static files load without rewrite - config_output.http.routers[`${siteResourceRouterName}-assets`] = { - entryPoints: [config.getRawConfig().traefik.https_entrypoint], - service: siteResourceServiceName, - rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, - priority: 101, - tls - }; - } + }); } if (aiGatewayUrl) { @@ -1542,12 +866,7 @@ export async function getTraefikConfig( // recognize, so we pin the Host header to the gateway's own host // and smuggle the original resource host through in "p-host" // instead (same pattern as the maintenance-page routes above). - let aiGatewayHost: string | undefined; - try { - aiGatewayHost = new URL(aiGatewayUrl).host; - } catch { - aiGatewayHost = undefined; - } + const aiGatewayHost = getAiGatewayHost(aiGatewayUrl); // The p-host smuggling above is only necessary when the AI gateway // is overridden to a different host than the resource's own. In the @@ -1556,54 +875,18 @@ export async function getTraefikConfig( const aiGatewayOverride = config.getRawConfig().server.ai_gateway_override; - // The trust token is the same for every inference route on this exit - // node, so it's defined once here and attached to each router below - // instead of being duplicated into a per-resource middleware. Two - // variants exist (public resource vs. siteResource) so the resource - // type header lets the gateway know which kind of router the - // request came through without re-deriving it from resourceId. - const aiGatewayTrustMiddlewareNameResource = - "ai-gateway-trust-headers-resource"; - const aiGatewayTrustMiddlewareNameSiteResource = - "ai-gateway-trust-headers-site-resource"; - config_output.http.middlewares[aiGatewayTrustMiddlewareNameResource] = { - headers: { - customRequestHeaders: { - [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(), - [AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource" - } - } - }; - config_output.http.middlewares[ - aiGatewayTrustMiddlewareNameSiteResource - ] = { - headers: { - customRequestHeaders: { - [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(), - [AI_GATEWAY_RESOURCE_TYPE_HEADER]: "site-resource" - } - } - }; + Object.assign( + config_output.http.middlewares, + buildAiGatewayTrustMiddlewares() + ); - // Opt-in: a Badger instance with forward auth disabled, used only - // to stamp the resolved client IP into a dedicated header before - // the request reaches whatever sits between Traefik and the AI - // gateway. Only the site-resource router below needs this - it's - // the only path that resolves request identity from the client IP - // (see resolveRequestUser in aiGateway/pipeline.ts) - and it's the - // only inference router that doesn't already run Badger. - const aiGatewayClientIpMiddlewareName = "ai-gateway-client-ip"; - const enableAiGatewayClientIpHeader = - config.getRawConfig().server.enable_ai_gateway_client_ip_header; - if (enableAiGatewayClientIpHeader) { - config_output.http.middlewares[aiGatewayClientIpMiddlewareName] = { - plugin: { - badger: { - disableForwardAuth: true, - realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER - } - } - }; + const aiGatewayClientIpMiddleware = buildAiGatewayClientIpMiddleware(); + const enableAiGatewayClientIpHeader = !!aiGatewayClientIpMiddleware; + if (aiGatewayClientIpMiddleware) { + Object.assign( + config_output.http.middlewares, + aiGatewayClientIpMiddleware + ); } // Public inference resources: same TLS/cert-resolver handling as @@ -1621,44 +904,16 @@ export async function getTraefikConfig( const routerName = `${irKey}-router`; const serviceName = `${irKey}-service`; - let rule: string; - if (ir.wildcard && fullDomain.startsWith("*.")) { - const escaped = fullDomain.slice(2).replace(/\./g, "\\."); - rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`; - } else { - rule = `Host(\`${fullDomain}\`)`; - } + const rule = buildHostRule(fullDomain, ir.wildcard); let tls: any = {}; if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { - const domainParts = fullDomain.split("."); - let wildCard; - if (domainParts.length <= 2) { - wildCard = `*.${domainParts.join(".")}`; - } else { - wildCard = `*.${domainParts.slice(1).join(".")}`; - } - if (!ir.subdomain) { - wildCard = fullDomain; - } - - const globalDefaultResolver = - config.getRawConfig().traefik.cert_resolver; - const globalDefaultPreferWildcard = - config.getRawConfig().traefik.prefer_wildcard_cert; - const resolverName = ir.domainCertResolver - ? ir.domainCertResolver.trim() - : globalDefaultResolver; - const preferWildcard = - ir.preferWildcardCert !== undefined && - ir.preferWildcardCert !== null - ? ir.preferWildcardCert - : globalDefaultPreferWildcard; - - tls = { - certResolver: resolverName, - ...(preferWildcard ? { domains: [{ main: wildCard }] } : {}) - }; + tls = buildWildcardTls({ + fullDomain, + hasSubdomain: !!ir.subdomain, + domainCertResolver: ir.domainCertResolver, + preferWildcardCert: ir.preferWildcardCert + }); } else { const matchingCert = validCerts.find( (cert) => cert.queriedDomain === fullDomain @@ -1675,54 +930,34 @@ export async function getTraefikConfig( config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares = [ badgerMiddlewareName, - aiGatewayTrustMiddlewareNameResource + AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE ]; if (aiGatewayOverride) { const irHeadersMiddlewareName = `${irKey}-headers-middleware`; - config_output.http.middlewares[irHeadersMiddlewareName] = { - headers: { - customRequestHeaders: { - ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": fullDomain - } - } - }; + config_output.http.middlewares[irHeadersMiddlewareName] = + buildAiGatewayHostHeaderMiddleware( + aiGatewayHost, + fullDomain + ); routerMiddlewares.push(irHeadersMiddlewareName); } routerMiddlewares.push(...additionalMiddlewares); - if (ir.ssl) { - config_output.http.routers[routerName + "-redirect"] = { - entryPoints: [ - config.getRawConfig().traefik.http_entrypoint - ], - middlewares: [redirectHttpsMiddlewareName], - service: serviceName, - rule, - priority: 100 - }; - } - - config_output.http.routers[routerName] = { - entryPoints: [ - ir.ssl - ? config.getRawConfig().traefik.https_entrypoint - : config.getRawConfig().traefik.http_entrypoint - ], - middlewares: routerMiddlewares, - service: serviceName, + const { routers, services } = buildAiGatewayRouterAndService({ + routerName, + serviceName, rule, + ssl: ir.ssl, + tls, priority: 100, - ...(ir.ssl ? { tls } : {}) - }; - - config_output.http.services[serviceName] = { - loadBalancer: { - servers: [{ url: aiGatewayUrl }] - } - }; + routerMiddlewares, + aiGatewayUrl, + redirectHttpsMiddlewareName + }); + Object.assign(config_output.http.routers, routers); + Object.assign(config_output.http.services, services); } if (exitNode) { @@ -1749,23 +984,13 @@ export async function getTraefikConfig( if ( !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns ) { - const domainParts = fullDomain.split("."); - const wildCard = - domainParts.length <= 2 - ? `*.${domainParts.join(".")}` - : `*.${domainParts.slice(1).join(".")}`; - - const globalDefaultResolver = - config.getRawConfig().traefik.cert_resolver; - const globalDefaultPreferWildcard = - config.getRawConfig().traefik.prefer_wildcard_cert; - - tls = { - certResolver: globalDefaultResolver, - ...(globalDefaultPreferWildcard - ? { domains: [{ main: wildCard }] } - : {}) - }; + // siteResource aliases don't have a per-domain cert + // resolver stored, so always fall back to the global + // defaults. + tls = buildWildcardTls({ + fullDomain, + hasSubdomain: true + }); } else { const matchingCert = validCerts.find( (cert) => cert.queriedDomain === fullDomain @@ -1782,58 +1007,36 @@ export async function getTraefikConfig( config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares: string[] = [ ...(enableAiGatewayClientIpHeader - ? [aiGatewayClientIpMiddlewareName] + ? [AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME] : []), - aiGatewayTrustMiddlewareNameSiteResource + AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE ]; if (aiGatewayOverride) { const srHeadersMiddlewareName = `${srKey}-headers-middleware`; - config_output.http.middlewares[srHeadersMiddlewareName] = { - headers: { - customRequestHeaders: { - ...(aiGatewayHost - ? { Host: aiGatewayHost } - : {}), - "p-host": fullDomain - } - } - }; + config_output.http.middlewares[srHeadersMiddlewareName] = + buildAiGatewayHostHeaderMiddleware( + aiGatewayHost, + fullDomain + ); routerMiddlewares.push(srHeadersMiddlewareName); } routerMiddlewares.push(...additionalMiddlewares); - if (sr.ssl) { - config_output.http.routers[routerName + "-redirect"] = { - entryPoints: [ - config.getRawConfig().traefik.http_entrypoint - ], - middlewares: [redirectHttpsMiddlewareName], - service: serviceName, - rule, - priority: 200 // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. - }; - } - - config_output.http.routers[routerName] = { - entryPoints: [ - sr.ssl - ? config.getRawConfig().traefik.https_entrypoint - : config.getRawConfig().traefik.http_entrypoint - ], - middlewares: routerMiddlewares, - service: serviceName, + const { routers, services } = buildAiGatewayRouterAndService({ + routerName, + serviceName, rule, + ssl: sr.ssl, + tls, priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. - ...(sr.ssl ? { tls } : {}) - }; - - config_output.http.services[serviceName] = { - loadBalancer: { - servers: [{ url: aiGatewayUrl }] - } - }; + routerMiddlewares, + aiGatewayUrl, + redirectHttpsMiddlewareName + }); + Object.assign(config_output.http.routers, routers); + Object.assign(config_output.http.services, services); } } } diff --git a/server/private/license/license.ts b/server/private/license/license.ts index 81aae1439..649c3ab2e 100644 --- a/server/private/license/license.ts +++ b/server/private/license/license.ts @@ -104,11 +104,18 @@ LQIDAQAB } public async forceRecheck() { - this.statusCache.flushAll(); - this.licenseKeyCache.flushAll(); this.phoneHomeFailureCount = 0; - return await this.check(); + // Force a fresh check without discarding the last known good cache + // up front — check() only replaces the cache once it has a fresh + // result, so a failed recheck (e.g. a transient server error) won't + // leave listKeys()/status looking empty in the meantime. + this.doRecheck = true; + try { + return await this.check(); + } finally { + this.doRecheck = false; + } } public async isUnlocked(): Promise { @@ -181,10 +188,15 @@ LQIDAQAB } let foundHostKey = false; + // Keys that fully decrypted, to phone home with. A row that + // fails to decrypt (e.g. stored under a different server + // secret) is marked invalid below but excluded here, so it + // can't take down validation for every other key in the batch. + const keys: { licenseKey: string; instanceId: string }[] = []; // Validate stored license keys for (const key of allKeysRes) { try { - // Decrypt the license key and token + // Decrypt the license key, token, and instance ID const decryptedKey = decrypt( key.licenseKeyId, this.serverSecret @@ -193,6 +205,10 @@ LQIDAQAB key.token, this.serverSecret ); + const decryptedInstanceId = decrypt( + key.instanceId, + this.serverSecret + ); const payload = validateJWT( decryptedToken, @@ -214,6 +230,11 @@ LQIDAQAB if (payload.type === "host") { foundHostKey = true; } + + keys.push({ + licenseKey: decryptedKey, + instanceId: decryptedInstanceId + }); } catch (e) { logger.error( `Error validating license key: ${key.licenseKeyId}` @@ -233,37 +254,36 @@ LQIDAQAB status.isHostLicensed = false; } - const keys = allKeysRes.map((key) => ({ - licenseKey: decrypt(key.licenseKeyId, this.serverSecret), - instanceId: decrypt(key.instanceId, this.serverSecret) - })); - let apiResponse: ValidateLicenseAPIResponse | undefined; - try { - // Phone home to validate license keys - apiResponse = await this.phoneHome(keys, false); + if (keys.length > 0) { + try { + // Phone home to validate license keys + apiResponse = await this.phoneHome(keys, false); - if (!apiResponse?.success) { - throw new Error(apiResponse?.error); - } - // Reset failure count on success - this.phoneHomeFailureCount = 0; - } catch (e) { - this.phoneHomeFailureCount++; - if (this.phoneHomeFailureCount === 1) { - // First failure: fail silently - logger.error("Error communicating with license server:"); - logger.error(e); - logger.error( - `Allowing failure. Will retry one more time at next run interval.` - ); - // return last known good status - return this.statusCache.get( - this.statusKey - ) as LicenseStatus; - } else { - // Subsequent failures: fail abruptly - throw e; + if (!apiResponse?.success) { + throw new Error(apiResponse?.error); + } + // Reset failure count on success + this.phoneHomeFailureCount = 0; + } catch (e) { + this.phoneHomeFailureCount++; + if (this.phoneHomeFailureCount === 1) { + // First failure: fail silently + logger.error( + "Error communicating with license server:" + ); + logger.error(e); + logger.error( + `Allowing failure. Will retry one more time at next run interval.` + ); + // return last known good status + return this.statusCache.get( + this.statusKey + ) as LicenseStatus; + } else { + // Subsequent failures: fail abruptly + throw e; + } } } diff --git a/server/private/middlewares/index.ts b/server/private/middlewares/index.ts index 4b598b4bf..11e10a7cf 100644 --- a/server/private/middlewares/index.ts +++ b/server/private/middlewares/index.ts @@ -11,7 +11,6 @@ * This file is not licensed under the AGPLv3. */ -export * from "./verifyCertificateAccess"; export * from "./verifyRemoteExitNodeAccess"; export * from "./verifyIdpAccess"; export * from "./verifyLoginPageAccess"; diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index 84a7b4f5a..b32d83f7e 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -295,8 +295,8 @@ async function disableFeature( await disableRotateCredentials(orgId); break; - case TierFeature.MaintencePage: - await disableMaintencePage(orgId); + case TierFeature.MaintenancePage: + await disablemaintenancePage(orgId); break; case TierFeature.DevicePosture: @@ -319,10 +319,6 @@ async function disableFeature( await disableAutoProvisioning(orgId); break; - case TierFeature.AdvancedPrivateResources: - await disableAdvancedPrivateResources(orgId); - break; - case TierFeature.FullRbac: await disableFullRbac(orgId); break; @@ -368,13 +364,6 @@ async function disableDeviceApprovals(orgId: string): Promise { logger.info(`Disabled device approvals on all roles for org ${orgId}`); } -async function disableAdvancedPrivateResources(orgId: string): Promise { - // TODO: implement logic to disable advanced private resourcs like ssh and ssh pam - // logger.info( - // `Disabled advanced private resources on all roles and site resources for org ${orgId}` - // ); -} - async function disableFullRbac(orgId: string): Promise { logger.info(`Disabled full RBAC for org ${orgId}`); } @@ -506,7 +495,7 @@ async function disableConnectionLogs(orgId: string): Promise { async function disableRotateCredentials(orgId: string): Promise {} -async function disableMaintencePage(orgId: string): Promise { +async function disablemaintenancePage(orgId: string): Promise { await db .update(resources) .set({ diff --git a/server/private/routers/browserGatewayTarget/index.ts b/server/private/routers/browserGatewayTarget/index.ts deleted file mode 100644 index 3c1b3d6f9..000000000 --- a/server/private/routers/browserGatewayTarget/index.ts +++ /dev/null @@ -1,14 +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 "./getBrowserTarget"; diff --git a/server/private/routers/certificates/createCertificate.ts b/server/private/routers/certificates/createCertificate.ts deleted file mode 100644 index 2f2e50fdc..000000000 --- a/server/private/routers/certificates/createCertificate.ts +++ /dev/null @@ -1,115 +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 { Certificate, certificates, db, domains } from "@server/db"; -import logger from "@server/logger"; -import { Transaction } from "@server/db"; -import { eq, or, and, like } from "drizzle-orm"; - -/** - * Checks if a certificate exists for the given domain. - * If not, creates a new certificate in 'pending' state. - * Wildcard certs cover subdomains. - */ -export async function createCertificate( - domainId: string, - domain: string, - trx: Transaction | typeof db -) { - const [domainRecord] = await trx - .select() - .from(domains) - .where(eq(domains.domainId, domainId)) - .limit(1); - - if (!domainRecord) { - throw new Error(`Domain with ID ${domainId} not found`); - } - - let existing: Certificate[] = []; - if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { - const domainLevelDown = domain.split(".").slice(1).join("."); - const wildcardPrefixed = `*.${domainLevelDown}`; - - existing = await trx - .select() - .from(certificates) - .where( - and( - eq(certificates.domainId, domainId), - or( - eq(certificates.domain, domain), - and( - eq(certificates.wildcard, true), - or( - eq(certificates.domain, domainLevelDown), - eq(certificates.domain, wildcardPrefixed) - ) - ) - ) - ) - ); - } else { - // For non-NS domains, we only match exact domain names - existing = await trx - .select() - .from(certificates) - .where( - and( - eq(certificates.domainId, domainId), - eq(certificates.domain, domain) // exact match for non-NS domains - ) - ); - } - - if (existing.length > 0) { - logger.info(`Certificate already exists for domain ${domain}`); - return; - } - - let domainToWrite = domain; - if ( - domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD - domainRecord.preferWildcardCert && - !domain.startsWith("*.") - ) { - // in this case traefik is going to generate a domain one level down so we need to store it that way - const parts = domain.split("."); - if (parts.length > 2) { - domainToWrite = parts.slice(1).join("."); - domainToWrite = `*.${domainToWrite}`; - } - } else if (domainRecord.type == "ns") { - if (domain == domainRecord.baseDomain) { - domainToWrite = domainRecord.baseDomain; - } else { - const parts = domain.split("."); - if (parts.length > 2) { - domainToWrite = parts.slice(1).join("."); - } - } - } - - // No cert found, create a new one in pending state - await trx.insert(certificates).values({ - domain: domainToWrite, - domainId, - wildcard: - domainRecord.type == "ns" || - (domainRecord.type == "wildcard" && - domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains - status: "pending", - updatedAt: Math.floor(Date.now() / 1000), - createdAt: Math.floor(Date.now() / 1000) - }); -} diff --git a/server/private/routers/certificates/index.ts b/server/private/routers/certificates/index.ts deleted file mode 100644 index 54b11aa1e..000000000 --- a/server/private/routers/certificates/index.ts +++ /dev/null @@ -1,17 +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 "./getCertificate"; -export * from "./restartCertificate"; -export * from "./syncCertToNewts"; -export * from "./getBatchedCertificates"; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index fab026418..3ea095bd1 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -11,7 +11,6 @@ * This file is not licensed under the AGPLv3. */ -import * as certificates from "#private/routers/certificates"; import { createStore } from "#private/lib/rateLimitStore"; import * as billing from "#private/routers/billing"; import * as remoteExitNode from "#private/routers/remoteExitNode"; @@ -20,19 +19,16 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as domain from "#private/routers/domain"; import * as auth from "#private/routers/auth"; import * as license from "#private/routers/license"; -import * as generateLicense from "./generatedLicense"; +import * as generateLicense from "#private/routers/generatedLicense"; import * as logs from "#private/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 ssh from "#private/routers/ssh"; import * as user from "#private/routers/user"; import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; import * as healthChecks from "#private/routers/healthChecks"; -import * as client from "@server/routers/client"; -import * as resource from "#private/routers/resource"; import * as policy from "#private/routers/policy"; import { @@ -53,7 +49,6 @@ import { import { ActionsEnum } from "@server/auth/actions"; import { logActionAudit, - verifyCertificateAccess, verifyIdpAccess, verifyLoginPageAccess, verifyRemoteExitNodeAccess, @@ -167,32 +162,6 @@ authenticated.get( orgIdp.listUserAdminOrgIdps ); -authenticated.get( - "/org/:orgId/certificate/:domainId/:domain", - verifyOrgAccess, - verifyCertificateAccess, - verifyUserHasAction(ActionsEnum.getCertificate), - certificates.getCertificate -); - -authenticated.get( - "/org/:orgId/batched-certificates", - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.getCertificate), - certificates.getBatchedCertificates -); - -authenticated.post( - "/org/:orgId/certificate/:certId/restart", - verifyValidLicense, - verifyOrgAccess, - verifyCertificateAccess, - verifyLimits, - verifyUserHasAction(ActionsEnum.restartCertificate), - logActionAudit(ActionsEnum.restartCertificate), - certificates.restartCertificate -); - if (build === "saas") { authenticated.post( "/org/:orgId/billing/create-checkout-session", @@ -652,17 +621,6 @@ authenticated.put( reKey.reGenerateExitNodeSecret ); -authenticated.post( - "/org/:orgId/ssh/sign-key", - verifyValidLicense, - verifyValidSubscription(tierMatrix.advancedPrivateResources), - verifyOrgAccess, - verifyLimits, - // verifyUserHasAction(ActionsEnum.signSshKey), // this check happens inside of the function now - // logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata - ssh.signSshKey -); - authenticated.post( "/user/:userId/add-role/:roleId", verifyRoleAccess, @@ -868,18 +826,6 @@ authenticated.get( healthChecks.getBatchedHealthCheckStatusHistory ); -authenticated.get( - "/client/:clientId/verify-associations-cache", - verifyClientAccess, - client.verifyClientAssociationsCache -); - -authenticated.post( - "/client/:clientId/rebuild-associations-cache", - verifyClientAccess, - client.rebuildClientAssociationsCacheRoute -); - authenticated.post( "/org/:orgId/logs/access/attempt", verifyOrgAccess, diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 2e53b5b9d..8a1e15c2f 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -15,7 +15,7 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; import * as alertEvents from "#private/routers/alertEvents"; -import * as certificates from "#private/routers/certificates"; +import * as certificates from "@server/routers/certificates"; import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as policy from "#private/routers/policy"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; diff --git a/server/private/routers/internal.ts b/server/private/routers/internal.ts index c45fe36b9..29b9b9506 100644 --- a/server/private/routers/internal.ts +++ b/server/private/routers/internal.ts @@ -17,14 +17,8 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as billing from "#private/routers/billing"; import * as license from "#private/routers/license"; import * as resource from "#private/routers/resource"; -import * as ssh from "#private/routers/ssh"; -import * as ws from "@server/routers/ws"; -import * as browserTarget from "#private/routers/browserGatewayTarget"; -import { - verifySessionUserMiddleware, - verifyUserFromResourceSessionMiddleware -} from "@server/middlewares"; +import { verifySessionUserMiddleware } from "@server/middlewares"; import { internalRouter as ir } from "@server/routers/internal"; @@ -46,17 +40,3 @@ internalRouter.post( internalRouter.get(`/license/status`, license.getLicenseStatus); internalRouter.get("/maintenance/info", resource.getMaintenanceInfo); - -internalRouter.post( - "/org/:orgId/ssh/sign-key", - verifyUserFromResourceSessionMiddleware, - ssh.signSshKey -); - -internalRouter.get( - "/ws/round-trip-message/:messageId", - verifyUserFromResourceSessionMiddleware, - ws.checkRoundTripMessage -); - -internalRouter.get("/resource/browser-target", browserTarget.getBrowserTarget); diff --git a/server/private/routers/loginPage/createLoginPage.ts b/server/private/routers/loginPage/createLoginPage.ts index 044d292fb..6336d8e76 100644 --- a/server/private/routers/loginPage/createLoginPage.ts +++ b/server/private/routers/loginPage/createLoginPage.ts @@ -29,7 +29,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; -import { createCertificate } from "#private/routers/certificates/createCertificate"; +import { createCertificate } from "@server/routers/certificates/createCertificate"; import { CreateLoginPageResponse } from "@server/routers/loginPage/types"; diff --git a/server/private/routers/loginPage/updateLoginPage.ts b/server/private/routers/loginPage/updateLoginPage.ts index 679d03fbc..6cc873729 100644 --- a/server/private/routers/loginPage/updateLoginPage.ts +++ b/server/private/routers/loginPage/updateLoginPage.ts @@ -22,7 +22,7 @@ import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { subdomainSchema } from "@server/lib/schemas"; -import { createCertificate } from "#private/routers/certificates/createCertificate"; +import { createCertificate } from "@server/routers/certificates/createCertificate"; import { UpdateLoginPageResponse } from "@server/routers/loginPage/types"; @@ -85,7 +85,6 @@ export async function updateLoginPage( const { loginPageId, orgId } = parsedParams.data; - const [existingLoginPage] = await db .select() .from(loginPage) diff --git a/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts b/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts index 0d871583f..20bb46193 100644 --- a/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts +++ b/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts @@ -16,7 +16,7 @@ import { db, exitNodes, newts, sites } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; import redisManager from "#private/lib/redis"; -import { sendToClient } from "#private/routers/ws"; +// import { sendToClient } from "#private/routers/ws"; const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds @@ -150,47 +150,47 @@ async function processPendingReconnects(): Promise { `Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.` ); - await sendReconnectToNewts(exitNodeId); + // await sendReconnectToNewts(exitNodeId); await removePending(exitNodeId); } } -async function sendReconnectToNewts(exitNodeId: number): Promise { - try { - const connectedNewts = await db - .select({ newtId: newts.newtId }) - .from(newts) - .innerJoin(sites, eq(newts.siteId, sites.siteId)) - .where(eq(sites.exitNodeId, exitNodeId)); +// async function sendReconnectToNewts(exitNodeId: number): Promise { +// try { +// const connectedNewts = await db +// .select({ newtId: newts.newtId }) +// .from(newts) +// .innerJoin(sites, eq(newts.siteId, sites.siteId)) +// .where(eq(sites.exitNodeId, exitNodeId)); - if (connectedNewts.length === 0) { - logger.debug( - `No newts found for exit node ${exitNodeId}, nothing to reconnect` - ); - return; - } +// if (connectedNewts.length === 0) { +// logger.debug( +// `No newts found for exit node ${exitNodeId}, nothing to reconnect` +// ); +// return; +// } - logger.info( - `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}` - ); +// logger.info( +// `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}` +// ); - const reconnectMessage = { - type: "newt/wg/reconnect", - data: {} - }; +// const reconnectMessage = { +// type: "newt/wg/reconnect", +// data: {} +// }; - await Promise.allSettled( - connectedNewts.map(({ newtId }) => - sendToClient(newtId, reconnectMessage) - ) - ); - } catch (error) { - logger.error( - `Failed to send reconnect messages for exit node ${exitNodeId}`, - { error } - ); - } -} +// await Promise.allSettled( +// connectedNewts.map(({ newtId }) => +// sendToClient(newtId, reconnectMessage) +// ) +// ); +// } catch (error) { +// logger.error( +// `Failed to send reconnect messages for exit node ${exitNodeId}`, +// { error } +// ); +// } +// } async function removePending(exitNodeId: number): Promise { pendingReconnects.delete(exitNodeId); diff --git a/server/private/routers/ssh/index.ts b/server/private/routers/ssh/index.ts deleted file mode 100644 index d2f607f81..000000000 --- a/server/private/routers/ssh/index.ts +++ /dev/null @@ -1,14 +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 "./signSshKey"; \ No newline at end of file diff --git a/server/private/routers/ws/messageHandlers.ts b/server/private/routers/ws/messageHandlers.ts index d91726393..b79b715b6 100644 --- a/server/private/routers/ws/messageHandlers.ts +++ b/server/private/routers/ws/messageHandlers.ts @@ -13,22 +13,17 @@ import { handleRemoteExitNodeRegisterMessage, - handleRemoteExitNodePingMessage, - startRemoteExitNodeOfflineChecker, - startExitNodeReconnectScheduler + handleRemoteExitNodePingMessage } from "#private/routers/remoteExitNode"; import { MessageHandler } from "@server/routers/ws"; -import { build } from "@server/build"; -import { handleConnectionLogMessage, handleRequestLogMessage } from "#private/routers/newt"; +import { + handleConnectionLogMessage, + handleRequestLogMessage +} from "#private/routers/newt"; export const messageHandlers: Record = { "remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/ping": handleRemoteExitNodePingMessage, "newt/access-log": handleConnectionLogMessage, - "newt/request-log": handleRequestLogMessage, + "newt/request-log": handleRequestLogMessage }; - -if (build != "saas") { - startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes - startExitNodeReconnectScheduler(); // check pending exit node reconnects and notify newts -} diff --git a/server/private/startSchedulers.ts b/server/private/startSchedulers.ts new file mode 100644 index 000000000..63237fb42 --- /dev/null +++ b/server/private/startSchedulers.ts @@ -0,0 +1,25 @@ +/* + * 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 { build } from "@server/build"; +import { startRemoteExitNodeOfflineChecker } from "./routers/remoteExitNode"; +import { startExitNodeReconnectScheduler } from "./routers/remoteExitNode/exitNodeReconnectScheduler"; +import { startSchedulers as ossStartSchedulers } from "@server/startSchedulers"; + +export function startSchedulers() { + if (build != "saas") { + startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes + startExitNodeReconnectScheduler(); // check pending exit node reconnects and notify newts + } + ossStartSchedulers(); +} diff --git a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts b/server/routers/browserGatewayTarget/getBrowserTarget.ts similarity index 89% rename from server/private/routers/browserGatewayTarget/getBrowserTarget.ts rename to server/routers/browserGatewayTarget/getBrowserTarget.ts index b8e32d836..1e95de5bc 100644 --- a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts +++ b/server/routers/browserGatewayTarget/getBrowserTarget.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, resources, targets } from "@server/db"; diff --git a/server/routers/browserGatewayTarget/index.ts b/server/routers/browserGatewayTarget/index.ts index eea524d65..614b7621d 100644 --- a/server/routers/browserGatewayTarget/index.ts +++ b/server/routers/browserGatewayTarget/index.ts @@ -1 +1,2 @@ export * from "./types"; +export * from "./getBrowserTarget"; diff --git a/server/routers/certificates/createCertificate.ts b/server/routers/certificates/createCertificate.ts index e858e5cda..e75bfe05f 100644 --- a/server/routers/certificates/createCertificate.ts +++ b/server/routers/certificates/createCertificate.ts @@ -1,9 +1,102 @@ -import { db, Transaction } from "@server/db"; +import { Certificate, certificates, db, domains } from "@server/db"; +import logger from "@server/logger"; +import { Transaction } from "@server/db"; +import { eq, or, and, like } from "drizzle-orm"; +/** + * Checks if a certificate exists for the given domain. + * If not, creates a new certificate in 'pending' state. + * Wildcard certs cover subdomains. + */ export async function createCertificate( domainId: string, domain: string, trx: Transaction | typeof db ) { - return; + const [domainRecord] = await trx + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .limit(1); + + if (!domainRecord) { + throw new Error(`Domain with ID ${domainId} not found`); + } + + let existing: Certificate[] = []; + if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { + const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; + + existing = await trx + .select() + .from(certificates) + .where( + and( + eq(certificates.domainId, domainId), + or( + eq(certificates.domain, domain), + and( + eq(certificates.wildcard, true), + or( + eq(certificates.domain, domainLevelDown), + eq(certificates.domain, wildcardPrefixed) + ) + ) + ) + ) + ); + } else { + // For non-NS domains, we only match exact domain names + existing = await trx + .select() + .from(certificates) + .where( + and( + eq(certificates.domainId, domainId), + eq(certificates.domain, domain) // exact match for non-NS domains + ) + ); + } + + if (existing.length > 0) { + logger.info(`Certificate already exists for domain ${domain}`); + return; + } + + let domainToWrite = domain; + if ( + domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD + domainRecord.preferWildcardCert && + !domain.startsWith("*.") + ) { + // in this case traefik is going to generate a domain one level down so we need to store it that way + const parts = domain.split("."); + if (parts.length > 2) { + domainToWrite = parts.slice(1).join("."); + domainToWrite = `*.${domainToWrite}`; + } + } else if (domainRecord.type == "ns") { + if (domain == domainRecord.baseDomain) { + domainToWrite = domainRecord.baseDomain; + } else { + const parts = domain.split("."); + if (parts.length > 2) { + domainToWrite = parts.slice(1).join("."); + } + } + } + + // No cert found, create a new one in pending state + await trx.insert(certificates).values({ + domain: domainToWrite, + domainId, + wildcard: + domainRecord.type == "ns" || + (domainRecord.type == "wildcard" && + domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains + status: "pending", + updatedAt: Math.floor(Date.now() / 1000), + createdAt: Math.floor(Date.now() / 1000) + }); } diff --git a/server/private/routers/certificates/getBatchedCertificates.ts b/server/routers/certificates/getBatchedCertificates.ts similarity index 94% rename from server/private/routers/certificates/getBatchedCertificates.ts rename to server/routers/certificates/getBatchedCertificates.ts index 2ab5fd288..caa179b6e 100644 --- a/server/private/routers/certificates/getBatchedCertificates.ts +++ b/server/routers/certificates/getBatchedCertificates.ts @@ -1,15 +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 { certificates, db, domainNamespaces, domains, orgDomains } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; diff --git a/server/private/routers/certificates/getCertificate.ts b/server/routers/certificates/getCertificate.ts similarity index 93% rename from server/private/routers/certificates/getCertificate.ts rename to server/routers/certificates/getCertificate.ts index 60a6de59f..deb816cea 100644 --- a/server/private/routers/certificates/getCertificate.ts +++ b/server/routers/certificates/getCertificate.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 { certificates, db, domains } from "@server/db"; diff --git a/server/routers/certificates/index.ts b/server/routers/certificates/index.ts new file mode 100644 index 000000000..b5177c19d --- /dev/null +++ b/server/routers/certificates/index.ts @@ -0,0 +1,5 @@ +export * from "./getCertificate"; +export * from "./restartCertificate"; +export * from "./syncCertToNewts"; +export * from "./getBatchedCertificates"; +export * from "./createCertificate"; diff --git a/server/private/routers/certificates/restartCertificate.ts b/server/routers/certificates/restartCertificate.ts similarity index 89% rename from server/private/routers/certificates/restartCertificate.ts rename to server/routers/certificates/restartCertificate.ts index 05614b078..5c758e7e0 100644 --- a/server/private/routers/certificates/restartCertificate.ts +++ b/server/routers/certificates/restartCertificate.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 { certificates, db } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; diff --git a/server/private/routers/certificates/syncCertToNewts.ts b/server/routers/certificates/syncCertToNewts.ts similarity index 77% rename from server/private/routers/certificates/syncCertToNewts.ts rename to server/routers/certificates/syncCertToNewts.ts index ac6089acb..e843af018 100644 --- a/server/private/routers/certificates/syncCertToNewts.ts +++ b/server/routers/certificates/syncCertToNewts.ts @@ -1,19 +1,6 @@ -/* - * 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 { pushCertUpdateToAffectedNewts } from "#private/lib/acmeCertSync"; +import { pushCertUpdateToAffectedNewts } from "@server/lib/acmeCertSync"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -65,4 +52,4 @@ export async function syncCertToNewts( ) ); } -} \ No newline at end of file +} diff --git a/server/routers/external.ts b/server/routers/external.ts index b51fbf2de..2d91868bc 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -20,6 +20,7 @@ import * as logs from "./auditLogs"; import * as launcher from "./launcher"; import * as newt from "./newt"; import * as olm from "./olm"; +import * as ssh from "./ssh"; import * as serverInfo from "./serverInfo"; import HttpCode from "@server/types/HttpCode"; import { @@ -49,19 +50,21 @@ import { verifyAiProviderAccess, verifyAiModelAccess, verifyAiBudgetAccess, - verifyVirtualApiKeyAccess + verifyVirtualApiKeyAccess, + logActionAudit, + verifyCertificateAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; import { build } from "@server/build"; import { createStore } from "#dynamic/lib/rateLimitStore"; -import { logActionAudit } from "#dynamic/middlewares"; import { checkRoundTripMessage } from "./ws"; import * as labels from "@server/routers/labels"; import * as aiProvider from "@server/routers/aiProvider"; import * as aiBudget from "@server/routers/aiBudget"; import * as virtualApiKey from "@server/routers/virtualApiKey"; +import * as certificates from "@server/routers/certificates"; // Root routes export const unauthenticated = Router(); @@ -1863,6 +1866,52 @@ authenticated.put( labels.detachLabelFromItem ); +authenticated.post( + "/org/:orgId/ssh/sign-key", + verifyOrgAccess, + verifyLimits, + // verifyUserHasAction(ActionsEnum.signSshKey), // this check happens inside of the function now + // logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata + ssh.signSshKey +); + +authenticated.get( + "/client/:clientId/verify-associations-cache", + verifyClientAccess, + client.verifyClientAssociationsCache +); + +authenticated.post( + "/client/:clientId/rebuild-associations-cache", + verifyClientAccess, + client.rebuildClientAssociationsCacheRoute +); + +authenticated.get( + "/org/:orgId/certificate/:domainId/:domain", + verifyOrgAccess, + verifyCertificateAccess, + verifyUserHasAction(ActionsEnum.getCertificate), + certificates.getCertificate +); + +authenticated.get( + "/org/:orgId/batched-certificates", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getCertificate), + certificates.getBatchedCertificates +); + +authenticated.post( + "/org/:orgId/certificate/:certId/restart", + verifyOrgAccess, + verifyCertificateAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.restartCertificate), + logActionAudit(ActionsEnum.restartCertificate), + certificates.restartCertificate +); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); diff --git a/server/routers/internal.ts b/server/routers/internal.ts index b06015a5f..0b0e28ad8 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -1,16 +1,20 @@ import { Router } from "express"; import * as gerbil from "@server/routers/gerbil"; import * as traefik from "@server/routers/traefik"; -import * as resource from "./resource"; -import * as badger from "./badger"; +import * as resource from "@server/routers/resource"; +import * as badger from "@server/routers/badger"; import * as auth from "@server/routers/auth"; import * as supporterKey from "@server/routers/supporterKey"; import * as idp from "@server/routers/idp"; +import * as ssh from "@server/routers/ssh"; import HttpCode from "@server/types/HttpCode"; import { verifyResourceAccess, - verifySessionUserMiddleware + verifySessionUserMiddleware, + verifyUserFromResourceSessionMiddleware } from "@server/middlewares"; +import * as ws from "@server/routers/ws"; +import * as browserTarget from "@server/routers/browserGatewayTarget"; // Root routes export const internalRouter = Router(); @@ -42,6 +46,12 @@ internalRouter.get("/idp", idp.listIdps); internalRouter.get("/idp/:idpId", idp.getIdp); +internalRouter.post( + "/org/:orgId/ssh/sign-key", + verifyUserFromResourceSessionMiddleware, + ssh.signSshKey +); + // Gerbil routes const gerbilRouter = Router(); internalRouter.use("/gerbil", gerbilRouter); @@ -64,3 +74,10 @@ badgerRouter.post("/verify-session", badger.verifyResourceSession); badgerRouter.post("/exchange-session", badger.exchangeSession); +internalRouter.get("/resource/browser-target", browserTarget.getBrowserTarget); + +internalRouter.get( + "/ws/round-trip-message/:messageId", + verifyUserFromResourceSessionMiddleware, + ws.checkRoundTripMessage +); diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index f7ea3018f..a4efc991d 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -33,7 +33,6 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor import { doCidrsOverlap } from "@server/lib/ip"; import { generateCA } from "@server/lib/sshCA"; import { encrypt } from "@server/lib/crypto"; -import { generateId } from "@server/auth/sessions/app"; const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/; diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index c0c7a7d5d..742ecaf6d 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -24,14 +24,14 @@ import logger from "@server/logger"; import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; -import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { createCertificate } from "@server/routers/certificates"; import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { getUniqueResourceName, getUniqueResourcePolicyName @@ -454,21 +454,6 @@ async function createHttpResource( } } - if ( - ["ssh", "rdp", "vnc"].includes(effectiveMode) && - !isLicensedOrSubscribed( - orgId!, - tierMatrix[TierFeature.AdvancedPublicResources] - ) - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Your current subscription does not support browser gateway resources. Please upgrade to access this feature." - ) - ); - } - // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, @@ -647,9 +632,7 @@ async function createHttpResource( ); } - if (build !== "oss") { - await createCertificate(domainId, fullDomain, db); - } + await createCertificate(domainId, fullDomain, db); return response(res, { data: resource, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 46e547b0b..842cdfa9f 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -38,7 +38,7 @@ import { } from "@server/lib/schemas"; import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; -import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { createCertificate } from "@server/routers/certificates/createCertificate"; import { validateAndConstructDomain, checkWildcardDomainConflict @@ -678,9 +678,7 @@ async function updateHttpResource( // Update the subdomain in the update data updateData.subdomain = finalSubdomain; - if (build != "oss") { - await createCertificate(domainId, fullDomain, db); - } + await createCertificate(domainId, fullDomain, db); } let headers = undefined; diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts index 6d1ecb503..1cce7a5ae 100644 --- a/server/routers/role/createRole.ts +++ b/server/routers/role/createRole.ts @@ -135,7 +135,7 @@ export async function createRole( const isLicensedSshPam = await isLicensedOrSubscribed( orgId, - tierMatrix.advancedPrivateResources + tierMatrix.roleBasedSSHControls ); const roleInsertValues: Record = { name: roleData.name, diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts index aa01899db..2c1dcc887 100644 --- a/server/routers/role/updateRole.ts +++ b/server/routers/role/updateRole.ts @@ -144,7 +144,7 @@ export async function updateRole( const isLicensedSshPam = await isLicensedOrSubscribed( orgId, - tierMatrix.advancedPrivateResources + tierMatrix.roleBasedSSHControls ); if (!isLicensedSshPam) { delete updateData.sshSudoMode; diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index f3794d35a..4c3593f4c 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -10,8 +10,7 @@ import { SiteResource, siteResources, sites, - userSiteResources, - primaryDb + userSiteResources } from "@server/db"; import { getUniqueSiteResourceName } from "@server/db/names"; import { @@ -19,8 +18,6 @@ import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; -import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { rebuildClientAssociationsFromSiteResource, isOrgRebuildRateLimited @@ -35,7 +32,7 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; -import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { createCertificate } from "@server/routers/certificates/createCertificate"; import { build } from "@server/build"; import { usageService } from "@server/lib/billing/usageService"; import { LimitId } from "@server/lib/billing"; @@ -408,21 +405,6 @@ export async function createSiteResource( } } - if (mode == "http") { - const hasHttpFeature = await isLicensedOrSubscribed( - orgId, - tierMatrix[TierFeature.AdvancedPrivateResources] - ); - if (!hasHttpFeature) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "HTTP private resources are not included in your current plan. Please upgrade." - ) - ); - } - } - // Verify the site exists and belongs to the org const sitesToAssign = await db .select() @@ -557,20 +539,6 @@ export async function createSiteResource( } } - const isLicensedSshPam = await isLicensedOrSubscribed( - orgId, - tierMatrix.advancedPrivateResources - ); - - if (mode == "ssh" && !isLicensedSshPam) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "SSH private resources are not included in your current plan. Please upgrade." - ) - ); - } - let updatedNiceId = niceId; if (!niceId) { updatedNiceId = await getUniqueSiteResourceName(orgId); @@ -646,13 +614,13 @@ export async function createSiteResource( fullDomain, requiresExitNodeConnection: mode === "inference" // in the future we might want to have different modes that do this }; - if (isLicensedSshPam) { - if (authDaemonPort !== undefined) - insertValues.authDaemonPort = authDaemonPort; - if (authDaemonMode !== undefined) - insertValues.authDaemonMode = authDaemonMode; - if (pamMode !== undefined) insertValues.pamMode = pamMode; - } + + if (authDaemonPort !== undefined) + insertValues.authDaemonPort = authDaemonPort; + if (authDaemonMode !== undefined) + insertValues.authDaemonMode = authDaemonMode; + if (pamMode !== undefined) insertValues.pamMode = pamMode; + [newSiteResource] = await trx .insert(siteResources) .values(insertValues) @@ -771,8 +739,7 @@ export async function createSiteResource( ssl && (mode === "http" || mode == "inference") && domainId && - fullDomain && - build != "oss" + fullDomain ) { await createCertificate(domainId, fullDomain, db); } diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 2e57b13a7..d5662eb16 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -10,8 +10,6 @@ import { sites, userSiteResources } from "@server/db"; -import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import response from "@server/lib/response"; import { eq, and, ne, inArray } from "drizzle-orm"; @@ -362,26 +360,6 @@ export async function updateSiteResource( ); } - if (mode == "http") { - const hasHttpFeature = await isLicensedOrSubscribed( - existingSiteResource.orgId, - tierMatrix[TierFeature.AdvancedPrivateResources] - ); - if (!hasHttpFeature) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "HTTP private resources are not included in your current plan. Please upgrade." - ) - ); - } - } - - const isLicensedSshPam = await isLicensedOrSubscribed( - existingSiteResource.orgId, - tierMatrix.advancedPrivateResources - ); - const [org] = await db .select() .from(orgs) @@ -541,10 +519,9 @@ export async function updateSiteResource( await db.transaction(async (trx) => { // Update the site resource const sshPamSet = - isLicensedSshPam && - (authDaemonPort !== undefined || - authDaemonMode !== undefined || - pamMode !== undefined) + authDaemonPort !== undefined || + authDaemonMode !== undefined || + pamMode !== undefined ? { ...(authDaemonPort !== undefined && { authDaemonPort @@ -741,8 +718,7 @@ export async function updateSiteResource( ssl && (mode === "http" || mode == "inference") && domainId && - fullDomain && - build != "oss" + fullDomain ) { await createCertificate(domainId, fullDomain, db); } diff --git a/server/routers/ssh/index.ts b/server/routers/ssh/index.ts new file mode 100644 index 000000000..4cfcb6df3 --- /dev/null +++ b/server/routers/ssh/index.ts @@ -0,0 +1 @@ +export * from "./signSshKey"; \ No newline at end of file diff --git a/server/private/routers/ssh/signSshKey.ts b/server/routers/ssh/signSshKey.ts similarity index 96% rename from server/private/routers/ssh/signSshKey.ts rename to server/routers/ssh/signSshKey.ts index ae74a07a0..1935bd6eb 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/routers/ssh/signSshKey.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 { randomInt } from "crypto"; import { z } from "zod"; @@ -34,9 +21,7 @@ import { Resource, SiteResource } from "@server/db"; -import { logAccessAudit } from "#private/lib/logAccessAudit"; -import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -47,7 +32,7 @@ import { canUserAccessResource } from "@server/auth/canUserAccessResource"; import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource"; import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA"; import config from "@server/lib/config"; -import { sendToClient } from "#private/routers/ws"; +import { sendToClient } from "#dynamic/routers/ws"; import { ActionsEnum } from "@server/auth/actions"; import type { SignSshKeyResponse } from "@server/routers/ssh/types"; @@ -163,19 +148,6 @@ export async function signSshKey( ); } - const isLicensed = await isLicensedOrSubscribed( - orgId, - tierMatrix.advancedPrivateResources - ); - if (!isLicensed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "SSH key signing requires a paid plan" - ) - ); - } - // Get and decrypt the org's CA keys const caKeys = await getOrgCAKeys( orgId, diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index fff2bf7c4..b8ac9baa4 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -1,4 +1,3 @@ -import { build } from "@server/build"; import { handleNewtRegisterMessage, handleReceiveBandwidthMessage, @@ -8,15 +7,12 @@ import { handleNewtExitNodesRequestMessage, handleApplyBlueprintMessage, handleNewtPingMessage, - startNewtOfflineChecker, handleNewtDisconnectingMessage } from "../newt"; -import { startPingAccumulator } from "../newt/pingAccumulator"; import { handleOlmRegisterMessage, handleOlmRelayMessage, handleOlmPingMessage, - startOlmOfflineChecker, handleOlmServerPeerAddMessage, handleOlmUnRelayMessage, handleOlmDisconnectingMessage, @@ -52,12 +48,3 @@ export const messageHandlers: Record = { "newt/healthcheck/status": handleHealthcheckStatusMessage, "ws/round-trip/complete": handleRoundTripMessage }; - -// Start the ping accumulator for all builds - it batches per-site online/lastPing -// updates into periodic bulk writes, preventing connection pool exhaustion. -startPingAccumulator(); - -if (build != "saas") { - startOlmOfflineChecker(); // this is to handle the offline check for olms - startNewtOfflineChecker(); // this is to handle the offline check for newts -} diff --git a/server/startSchedulers.ts b/server/startSchedulers.ts new file mode 100644 index 000000000..4a67495cc --- /dev/null +++ b/server/startSchedulers.ts @@ -0,0 +1,25 @@ +import { build } from "@server/build"; +import { startPingAccumulator } from "./routers/newt/pingAccumulator"; +import { startOlmOfflineChecker } from "./routers/olm"; +import { startNewtOfflineChecker } from "./routers/newt"; +import { initTelemetryClient } from "@server/lib/telemetry"; +import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; +import { initAcmeCertSync } from "@server/lib/acmeCertSync"; +import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations"; + +export function startSchedulers() { + // Start the ping accumulator for all builds - it batches per-site online/lastPing + // updates into periodic bulk writes, preventing connection pool exhaustion. + startPingAccumulator(); + + if (build != "saas") { + startOlmOfflineChecker(); // this is to handle the offline check for olms + startNewtOfflineChecker(); // this is to handle the offline check for newts + } + + initTelemetryClient(); + + initLogCleanupInterval(); + initAcmeCertSync(); + startRebuildQueueProcessor(); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx index c1263c162..c764b0446 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx @@ -35,10 +35,6 @@ import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; export default function PrivateResourceHttpPage() { const t = useTranslations(); const { save, siteResource } = useSaveSiteResource(); - const { isPaidUser } = usePaidStatus(); - const httpSectionDisabled = !isPaidUser( - tierMatrix.advancedPrivateResources - ); const [selectedSites, setSelectedSites] = useState(() => buildSelectedSitesForResource(siteResource) ); @@ -120,7 +116,7 @@ export default function PrivateResourceHttpPage() { )} orgId={siteResource.orgId} watch={asAnyWatch(form.watch)} - disabled={httpSectionDisabled} + disabled={false} siteResourceId={siteResource.id} /> @@ -135,7 +131,6 @@ export default function PrivateResourceHttpPage() { type="submit" form="private-resource-http-form" loading={saveLoading} - disabled={httpSectionDisabled} > {t("saveSettings")} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx index 9a739a073..ba60e41e6 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx @@ -12,16 +12,13 @@ import { SettingsFormGrid } from "@app/components/Settings"; import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { Button } from "@app/components/ui/button"; import { Form } from "@app/components/ui/form"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { createSshFormSchema, inferSshPamMode } from "@app/lib/privateResourceForm"; import { zodResolver } from "@hookform/resolvers/zod"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useTranslations } from "next-intl"; import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; @@ -39,8 +36,6 @@ import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; export default function PrivateResourceSshPage() { const t = useTranslations(); const { save, siteResource } = useSaveSiteResource(); - const { isPaidUser } = usePaidStatus(); - const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); const isNative = siteResource.authDaemonMode === "native"; const [sshServerMode] = useState<"standard" | "native">( isNative ? "native" : "standard" @@ -150,7 +145,6 @@ export default function PrivateResourceSshPage() { return ( - @@ -161,68 +155,56 @@ export default function PrivateResourceSshPage() { -
-
- - - - - form.setValue( - "authDaemonPort", - value, - { shouldValidate: true } - ) - } - authDaemonPortError={ - form.formState.errors.authDaemonPort - ?.message - } - sshServerMode={sshServerMode} - serverModeDisplay="badge" - /> - - - - + + + + + + form.setValue("authDaemonPort", value, { + shouldValidate: true + }) + } + authDaemonPortError={ + form.formState.errors.authDaemonPort + ?.message + } + sshServerMode={sshServerMode} + serverModeDisplay="badge" + /> + + + + - - - - -
- -
+ +
+ +
+
+
); diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index 96e8f84d8..b895ff5a3 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -16,7 +16,6 @@ import { type DescribedSelectOption } from "@app/components/DescribedSelect"; import DomainPicker from "@app/components/DomainPicker"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { Button } from "@app/components/ui/button"; import { Form, @@ -30,7 +29,6 @@ import { import { Input } from "@app/components/ui/input"; import type { Selectedsite } from "@app/components/site-selector"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { @@ -77,12 +75,6 @@ export default function CreatePrivateResourcePage() { const { env } = useEnvContext(); const api = createApiClient({ env }); const orgId = params.orgId as string; - const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures; - const { isPaidUser } = usePaidStatus(); - const httpSectionDisabled = !isPaidUser( - tierMatrix.advancedPrivateResources - ); - const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); const [isSubmitting, startTransition] = useTransition(); const siteIdParam = searchParams.get("siteId"); @@ -158,20 +150,16 @@ export default function CreatePrivateResourcePage() { title: t("createInternalResourceDialogModeCidr"), description: t("privateResourceTypeCidrDescription") }, - ...(!disableEnterpriseFeatures - ? [ - { - value: "http" as const, - title: t("createInternalResourceDialogModeHttp"), - description: t("privateResourceTypeHttpDescription") - }, - { - value: "ssh" as const, - title: t("createInternalResourceDialogModeSsh"), - description: t("privateResourceTypeSshDescription") - } - ] - : []), + { + value: "http" as const, + title: t("createInternalResourceDialogModeHttp"), + description: t("privateResourceTypeHttpDescription") + }, + { + value: "ssh" as const, + title: t("createInternalResourceDialogModeSsh"), + description: t("privateResourceTypeSshDescription") + }, { value: "inference" as const, title: t("createInternalResourceDialogModeInference"), @@ -179,11 +167,6 @@ export default function CreatePrivateResourcePage() { } ]; - const submitDisabled = - isSubmitting || - (mode === "http" && httpSectionDisabled) || - (mode === "ssh" && sshSectionDisabled); - function onSubmit(values: FormValues) { startTransition(async () => { try { @@ -467,10 +450,6 @@ export default function CreatePrivateResourcePage() { )} watch={asAnyWatch(form.watch)} labelPrefix="create" - disabled={ - mode === "ssh" && - sshSectionDisabled - } /> )} @@ -584,9 +563,6 @@ export default function CreatePrivateResourcePage() { {/* HTTP configuration */} {mode === "http" && ( - {t("httpSettings")} @@ -597,62 +573,43 @@ export default function CreatePrivateResourcePage() { )} -
- - - - - - - - - - - - -
+ + + + + + + + + + + + +
)} {/* SSH server */} {mode === "ssh" && ( - {t("sshSettings")} @@ -661,37 +618,22 @@ export default function CreatePrivateResourcePage() { {t("sshServerDescription")} -
- - - - - -
+ + + + +
)} @@ -776,7 +718,7 @@ export default function CreatePrivateResourcePage() { - ); } diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx index 7054740cf..f8182f858 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -75,11 +75,7 @@ export default function SshSettingsPage(props: { }) { const params = use(props.params); const { resource, updateResource } = useResourceContext(); - const { isPaidUser } = usePaidStatus(); const api = createApiClient(useEnvContext()); - const disabled = !isPaidUser( - tierMatrix[TierFeature.AdvancedPublicResources] - ); const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({ queryKey: ["resourceTargets", resource.resourceId, params.orgId, "ssh"], @@ -95,14 +91,10 @@ export default function SshSettingsPage(props: { return ( - @@ -113,13 +105,11 @@ function SshServerForm({ orgId, resource, updateResource, - disabled, targetsResponse }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; - disabled: boolean; targetsResponse: ResourceTargetsResponse; }) { const t = useTranslations(); @@ -375,10 +365,6 @@ function SshServerForm({ {t("sshServerDescription")} -
@@ -530,7 +516,6 @@ function SshServerForm({ -
); } diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx index 3efe29ee4..bee578012 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx @@ -55,11 +55,7 @@ export default function VncSettingsPage(props: { }) { const params = use(props.params); const { resource, updateResource } = useResourceContext(); - const { isPaidUser } = usePaidStatus(); const api = createApiClient(useEnvContext()); - const disabled = !isPaidUser( - tierMatrix[TierFeature.AdvancedPublicResources] - ); const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({ queryKey: ["resourceTargets", resource.resourceId, params.orgId, "vnc"], @@ -75,14 +71,10 @@ export default function VncSettingsPage(props: { return ( - @@ -92,13 +84,11 @@ export default function VncSettingsPage(props: { function VncServerForm({ orgId, resource, - disabled, targetsResponse }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; - disabled: boolean; targetsResponse: ResourceTargetsResponse; }) { const t = useTranslations(); @@ -215,10 +205,6 @@ function VncServerForm({ {t("vncServerDescription")} -
@@ -244,7 +230,6 @@ function VncServerForm({ -
); } diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index 667819c21..552e340d7 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -239,14 +239,6 @@ export default function Page() { // Resource type state const [resourceType, setResourceType] = useState("http"); - const isBrowserGatewayType = - resourceType === "ssh" || - resourceType === "rdp" || - resourceType === "vnc"; - const browserGatewayDisabled = - isBrowserGatewayType && - !isPaidUser(tierMatrix[TierFeature.AdvancedPublicResources]); - // Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit) const [targets, setTargets] = useState([]); const [selectedProviders, setSelectedProviders] = useState< @@ -1056,14 +1048,6 @@ export default function Page() { {/* SSH Server Section */} {resourceType === "ssh" && ( - {t("sshServer")} @@ -1072,14 +1056,7 @@ export default function Page() { {t("sshServerDescription")} -
+ @@ -1318,21 +1295,12 @@ export default function Page() { -
)} {/* RDP Server Section */} {resourceType === "rdp" && ( - {t("rdpServer")} @@ -1341,14 +1309,6 @@ export default function Page() { {t("rdpServerDescription")} -
@@ -1365,21 +1325,12 @@ export default function Page() {
-
)} {/* VNC Server Section */} {resourceType === "vnc" && ( - {t("vncServer")} @@ -1388,14 +1339,7 @@ export default function Page() { {t("vncServerDescription")} -
+
@@ -1412,7 +1356,6 @@ export default function Page() {
-
)} @@ -1527,7 +1470,6 @@ export default function Page() { loading={createLoading} disabled={ !areAllTargetsValid() || - browserGatewayDisabled || createLoading } > diff --git a/src/components/AuthPageSettings.tsx b/src/components/AuthPageSettings.tsx index d6375c7b5..a76f13232 100644 --- a/src/components/AuthPageSettings.tsx +++ b/src/components/AuthPageSettings.tsx @@ -399,7 +399,7 @@ function AuthPageSettings({ )} - {build !== "oss" && (build === "enterprise" || + {(build === "enterprise" || !isPaidUser( tierMatrix.loginPageDomain )) && diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx index 56af53c56..497bc4615 100644 --- a/src/components/CreateRoleForm.tsx +++ b/src/components/CreateRoleForm.tsx @@ -52,7 +52,7 @@ export default function CreateRoleForm({ requireDeviceApproval: values.requireDeviceApproval, allowSsh: values.allowSsh }; - if (isPaidUser(tierMatrix.advancedPrivateResources)) { + if (isPaidUser(tierMatrix.roleBasedSSHControls)) { payload.sshSudoMode = values.sshSudoMode; payload.sshCreateHomeDir = values.sshCreateHomeDir; payload.sshSudoCommands = diff --git a/src/components/EditRoleForm.tsx b/src/components/EditRoleForm.tsx index d02e30c0e..ab8bbd7e0 100644 --- a/src/components/EditRoleForm.tsx +++ b/src/components/EditRoleForm.tsx @@ -59,7 +59,7 @@ export default function EditRoleForm({ payload.name = values.name; payload.description = values.description || undefined; } - if (isPaidUser(tierMatrix.advancedPrivateResources)) { + if (isPaidUser(tierMatrix.roleBasedSSHControls)) { payload.sshSudoMode = values.sshSudoMode; payload.sshCreateHomeDir = values.sshCreateHomeDir; payload.sshSudoCommands = @@ -107,10 +107,7 @@ export default function EditRoleForm({ toast({ variant: "destructive", title: t("aiBudgetErrorSave"), - description: formatAxiosError( - e, - t("aiBudgetErrorSave") - ) + description: formatAxiosError(e, t("aiBudgetErrorSave")) }); } } diff --git a/src/components/PrivateResourceHttpFields.tsx b/src/components/PrivateResourceHttpFields.tsx index 43fb49c08..8578495d9 100644 --- a/src/components/PrivateResourceHttpFields.tsx +++ b/src/components/PrivateResourceHttpFields.tsx @@ -1,7 +1,6 @@ "use client"; import DomainPicker from "@app/components/DomainPicker"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { SettingsFormCell, SettingsFormGrid, @@ -25,7 +24,6 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useTranslations } from "next-intl"; import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form"; @@ -49,8 +47,7 @@ export function PrivateResourceHttpFields({ disabled = false, siteResourceId, labelPrefix = "edit", - hideDomainPicker = false, - hidePaidFeaturesAlert = false + hideDomainPicker = false }: PrivateResourceHttpFieldsProps) { const t = useTranslations(); const schemeLabelKey = @@ -88,14 +85,6 @@ export function PrivateResourceHttpFields({ return ( - {!hidePaidFeaturesAlert && ( - - - - )} - - {showPaidFeaturesAlert && layout === "default" && ( - - - - )} {sshSettingsFields} {destinationSection} diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index bc77fbc38..d15d53205 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -429,7 +429,6 @@ export default function PrivateResourcesTable({ const fullDomain = resourceRow.fullDomain; const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`; const did = - build !== "oss" && resourceRow.ssl && domainId != null && domainId !== "" && diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index af89b878b..a7cdc9e2b 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -468,7 +468,6 @@ export default function PublicResourcesTable({ const domainId = resourceRow.domainId; const certHostname = resourceRow.fullDomain; const showHttpsCertIndicator = - build !== "oss" && resourceRow.ssl && certHostname != null && certHostname !== ""; diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index 3ce7ced79..4480b959a 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -40,8 +40,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { const showCertificate = !!( isDomainResource && resource.domainId && - resource.fullDomain && - build != "oss" + resource.fullDomain ); const showType = !!(isDomainResource && resource.mode); const showAuth = resource.mode !== "inference"; diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx index 186da2a00..fdb21b8b1 100644 --- a/src/components/RoleForm.tsx +++ b/src/components/RoleForm.tsx @@ -212,7 +212,7 @@ export function RoleForm({ } }, [variant, role, form]); - const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); + const sshDisabled = !isPaidUser(tierMatrix.roleBasedSSHControls); const sshSudoMode = form.watch("sshSudoMode"); const isAdminRole = variant === "edit" && role?.isAdmin === true; const [pendingImport, setPendingImport] = @@ -235,12 +235,6 @@ export function RoleForm({ setAttemptedBudgetsSave(false); }, [variant, budgetsQuery.data]); - useEffect(() => { - if (sshDisabled) { - form.setValue("allowSsh", false); - } - }, [sshDisabled, form]); - async function handleFileDrop( file: File, field: RoleTextImportField @@ -487,115 +481,157 @@ export function RoleForm({ /> - {/* SSH tab - hidden when enterprise features are disabled */} - {!env.flags.disableEnterpriseFeatures && ( -
- - { - const allowSshOptions: OptionSelectOption< - "allow" | "disallow" - >[] = [ - { - value: "allow", - label: t("roleAllowSshAllow") - }, - { - value: "disallow", - label: t("roleAllowSshDisallow") - } - ]; - return ( - - - {t("roleAllowSsh")} - - - options={allowSshOptions} - value={ - sshDisabled - ? "disallow" - : field.value - ? "allow" - : "disallow" - } - onChange={(v) => { - if (sshDisabled) return; - field.onChange( - v === "allow" - ); - }} - cols={2} - disabled={sshDisabled} - /> - - {t( - "roleAllowSshDescription" - )} - - - - ); - }} - /> - { - const sudoOptions: OptionSelectOption[] = - [ - { - value: "none", - label: t("sshSudoModeNone") - }, - { - value: "full", - label: t("sshSudoModeFull") - }, - { - value: "commands", - label: t( - "sshSudoModeCommands" - ) +
+ { + const allowSshOptions: OptionSelectOption< + "allow" | "disallow" + >[] = [ + { + value: "allow", + label: t("roleAllowSshAllow") + }, + { + value: "disallow", + label: t("roleAllowSshDisallow") + } + ]; + return ( + + + {t("roleAllowSsh")} + + + options={allowSshOptions} + value={ + field.value + ? "allow" + : "disallow" } - ]; - return ( - - - {t("sshSudoMode")} - - - options={sudoOptions} - value={field.value} - onChange={field.onChange} - cols={3} - disabled={sshDisabled} - /> - - - ); - }} - /> - {sshSudoMode === "commands" && ( + onChange={(v) => { + field.onChange( + v === "allow" + ); + }} + cols={2} + /> + + {t("roleAllowSshDescription")} + + + + ); + }} + /> + {/* SSH tab - hidden when enterprise features are disabled */} + {!env.flags.disableEnterpriseFeatures && ( + <> + { + const sudoOptions: OptionSelectOption[] = + [ + { + value: "none", + label: t( + "sshSudoModeNone" + ) + }, + { + value: "full", + label: t( + "sshSudoModeFull" + ) + }, + { + value: "commands", + label: t( + "sshSudoModeCommands" + ) + } + ]; + return ( + + + {t("sshSudoMode")} + + + options={sudoOptions} + value={field.value} + onChange={ + field.onChange + } + cols={3} + disabled={sshDisabled} + /> + + + ); + }} + /> + {sshSudoMode === "commands" && ( + ( + + + {t("sshSudoCommands")} + + +