Merge branch 'free-me-up' into aig

This commit is contained in:
Owen
2026-08-17 14:54:05 -04:00
86 changed files with 3321 additions and 3843 deletions
-20
View File
@@ -29,25 +29,6 @@ import {
labels labels
} from "./schema"; } 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", { export const dnsChallenge = pgTable("dnsChallenges", {
dnsChallengeId: serial("dnsChallengeId").primaryKey(), dnsChallengeId: serial("dnsChallengeId").primaryKey(),
domain: varchar("domain", { length: 255 }).notNull(), domain: varchar("domain", { length: 255 }).notNull(),
@@ -633,7 +614,6 @@ export const trialNotifications = pgTable("trialNotifications", {
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
export type Certificate = InferSelectModel<typeof certificates>;
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>; export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
export type Customer = InferSelectModel<typeof customers>; export type Customer = InferSelectModel<typeof customers>;
export type Subscription = InferSelectModel<typeof subscriptions>; export type Subscription = InferSelectModel<typeof subscriptions>;
+21 -1
View File
@@ -763,7 +763,7 @@ export const roles = pgTable("roles", {
name: varchar("name").notNull(), name: varchar("name").notNull(),
description: varchar("description"), description: varchar("description"),
requireDeviceApproval: boolean("requireDeviceApproval").default(false), 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("[]"), sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true), sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
sshUnixGroups: text("sshUnixGroups").default("[]") 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<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>; export type Site = InferSelectModel<typeof sites>;
@@ -2117,3 +2136,4 @@ export type SiteResourceAiProvider = InferSelectModel<
>; >;
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>; export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>; export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
export type Certificate = InferSelectModel<typeof certificates>;
-20
View File
@@ -23,25 +23,6 @@ import {
users users
} from "./schema"; } 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", { export const dnsChallenge = sqliteTable("dnsChallenges", {
dnsChallengeId: integer("dnsChallengeId").primaryKey({ dnsChallengeId: integer("dnsChallengeId").primaryKey({
autoIncrement: true autoIncrement: true
@@ -628,7 +609,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
export type Certificate = InferSelectModel<typeof certificates>;
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>; export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
export type Customer = InferSelectModel<typeof customers>; export type Customer = InferSelectModel<typeof customers>;
export type Subscription = InferSelectModel<typeof subscriptions>; export type Subscription = InferSelectModel<typeof subscriptions>;
+21 -1
View File
@@ -995,7 +995,7 @@ export const roles = sqliteTable("roles", {
requireDeviceApproval: integer("requireDeviceApproval", { requireDeviceApproval: integer("requireDeviceApproval", {
mode: "boolean" mode: "boolean"
}).default(false), }).default(false),
sshSudoMode: text("sshSudoMode").default("none"), // "none" | "full" | "commands" sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"), sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default( sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
true 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<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>; export type Site = InferSelectModel<typeof sites>;
@@ -2111,3 +2130,4 @@ export type SiteResourceAiProvider = InferSelectModel<
>; >;
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>; export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>; export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
export type Certificate = InferSelectModel<typeof certificates>;
+3 -9
View File
@@ -22,14 +22,11 @@ import {
} from "@server/db"; } from "@server/db";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { setHostMeta } from "@server/lib/hostMeta"; import { setHostMeta } from "@server/lib/hostMeta";
import { initTelemetryClient } from "@server/lib/telemetry";
import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager"; import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
import { initCleanup } from "#dynamic/cleanup"; import { initCleanup } from "#dynamic/cleanup";
import { startSchedulers } from "#dynamic/startSchedulers";
import license from "#dynamic/license/license"; 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 { fetchServerIp } from "@server/lib/serverIpService";
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
import { initAiModelCatalog } from "@server/lib/aiModelCatalog"; import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
async function startServers() { async function startServers() {
@@ -44,13 +41,10 @@ async function startServers() {
await fetchServerIp(); await fetchServerIp();
initTelemetryClient();
initLogCleanupInterval();
initAcmeCertSync();
startRebuildQueueProcessor();
await initAiModelCatalog(); await initAiModelCatalog();
startSchedulers();
// Start all servers // Start all servers
const apiServer = createApiServer(); const apiServer = createApiServer();
const internalServer = createInternalServer(); const internalServer = createInternalServer();
+865 -2
View File
@@ -1,3 +1,866 @@
export function initAcmeCertSync(): void { import fs from "fs";
// stub 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<void> {
// 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<string | null> {
// 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<void> {
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<void> {
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<void> {
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<string>([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 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);
} }
+4 -6
View File
@@ -10,7 +10,7 @@ export enum TierFeature {
ActionLogs = "actionLogs", // set the retention period to none on downgrade ActionLogs = "actionLogs", // set the retention period to none on downgrade
ConnectionLogs = "connectionLogs", ConnectionLogs = "connectionLogs",
RotateCredentials = "rotateCredentials", RotateCredentials = "rotateCredentials",
MaintencePage = "maintencePage", // handle downgrade MaintenancePage = "maintenancePage", // handle downgrade
DevicePosture = "devicePosture", DevicePosture = "devicePosture",
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
@@ -25,8 +25,7 @@ export enum TierFeature {
WildcardSubdomain = "wildcardSubdomain", WildcardSubdomain = "wildcardSubdomain",
NewtAutoUpdate = "newtAutoUpdate", NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies", ResourcePolicies = "resourcePolicies",
AdvancedPublicResources = "advancedPublicResources", RoleBasedSSHControls = "roleBasedSSHControls"
AdvancedPrivateResources = "advancedPrivateResources"
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -39,7 +38,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"], [TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"], [TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.RotateCredentials]: ["tier1", "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.DevicePosture]: ["tier2", "tier3", "enterprise"],
[TierFeature.TwoFactorEnforcement]: [ [TierFeature.TwoFactorEnforcement]: [
"tier1", "tier1",
@@ -69,6 +68,5 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"], [TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
[TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"], [TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"]
[TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"]
}; };
+1 -27
View File
@@ -23,9 +23,7 @@ import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels";
import logger from "@server/logger"; import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip"; import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "@server/routers/certificates/createCertificate";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { build } from "@server/build"; import { build } from "@server/build";
import { LimitId } from "../billing"; import { LimitId } from "../billing";
import { usageService } from "../billing/usageService"; import { usageService } from "../billing/usageService";
@@ -128,30 +126,6 @@ export async function updatePrivateResources(
for (const [resourceNiceId, resourceData] of Object.entries( for (const [resourceNiceId, resourceData] of Object.entries(
config["client-resources"] 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 const [existingResource] = await trx
.select() .select()
.from(siteResources) .from(siteResources)
+3 -18
View File
@@ -1,5 +1,5 @@
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; 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 { hashPassword } from "@server/auth/password";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -51,9 +51,6 @@ import { tierMatrix } from "../billing/tierMatrix";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { Config, isTargetsOnlyResource, TargetData } from "./types"; import { Config, isTargetsOnlyResource, TargetData } from "./types";
import { getOrCreateLabelIds, syncResourceLabels } from "./labels"; 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 { LimitId } from "../billing";
import { usageService } from "../billing/usageService"; import { usageService } from "../billing/usageService";
import { syncInferenceAiConfig } from "./aiProviders"; import { syncInferenceAiConfig } from "./aiProviders";
@@ -262,18 +259,6 @@ export async function updatePublicResources(
headers = JSON.stringify(resourceData.headers); 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) { if (resourceData.policy) {
const isLicensed = await isLicensedOrSubscribed( const isLicensed = await isLicensedOrSubscribed(
orgId, orgId,
@@ -331,7 +316,7 @@ export async function updatePublicResources(
const isLicensed = await isLicensedOrSubscribed( const isLicensed = await isLicensedOrSubscribed(
orgId, orgId,
tierMatrix.maintencePage tierMatrix.maintenancePage
); );
if (!isLicensed) { if (!isLicensed) {
resourceData.maintenance = undefined; resourceData.maintenance = undefined;
@@ -1138,7 +1123,7 @@ export async function updatePublicResources(
const isLicensed = await isLicensedOrSubscribed( const isLicensed = await isLicensedOrSubscribed(
orgId, orgId,
tierMatrix.maintencePage tierMatrix.maintenancePage
); );
if (!isLicensed) { if (!isLicensed) {
resourceData.maintenance = undefined; resourceData.maintenance = undefined;
+222 -13
View File
@@ -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( export async function getValidCertificatesForDomains(
domains: Set<string>, domains: Set<string>,
useCache: boolean = true useCache: boolean = true
): Promise< ): Promise<Array<CertificateResult>> {
Array<{ const finalResults: CertificateResult[] = [];
id: number; const domainsToQuery = new Set<string>();
domain: string;
queriedDomain: string; // 1. Check cache first if enabled
wildcard: boolean | null; if (useCache) {
certFile: string | null; for (const domain of domains) {
keyFile: string | null; const cacheKey = `cert:${domain}`;
expiresAt: number | null; const cachedCert = await cache.get<CertificateResult>(cacheKey);
updatedAt?: number | null; if (cachedCert) {
}> finalResults.push(cachedCert); // Valid cache hit
> { } else {
return []; // stub // 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<CertificateResult>(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<string>();
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<string, (typeof potentialCerts)[0]>();
const wildcardMatches = new Map<string, (typeof potentialCerts)[0]>();
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;
} }
+1 -1
View File
@@ -6,7 +6,7 @@ import z from "zod";
import logger from "@server/logger"; import logger from "@server/logger";
import semver from "semver"; import semver from "semver";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates"; import { getValidCertificatesForDomains } from "@server/lib/certificates";
import { lockManager } from "#dynamic/lib/lock"; import { lockManager } from "#dynamic/lib/lock";
interface IPRange { interface IPRange {
+1
View File
@@ -7,6 +7,7 @@ export async function logAccessAudit(data: {
type: string; type: string;
orgId: string; orgId: string;
resourceId?: number; resourceId?: number;
siteResourceId?: number;
user?: { username: string; userId: string }; user?: { username: string; userId: string };
apiKey?: { name: string | null; apiKeyId: string }; apiKey?: { name: string | null; apiKeyId: string };
metadata?: any; metadata?: any;
+14 -4
View File
@@ -167,9 +167,8 @@ export const configSchema = z
.transform((val) => .transform((val) =>
process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER !== process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER !==
undefined undefined
? process.env ? process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER ===
.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER === "true"
"true"
: val : val
), ),
secret: z.string().pipe(z.string().min(8)).optional(), 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_basic_wireguard_sites: z.boolean().optional(),
disable_config_managed_domains: z.boolean().optional(), disable_config_managed_domains: z.boolean().optional(),
disable_product_help_banners: 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(), .optional(),
ai: z ai: z
+2 -3
View File
@@ -8,7 +8,7 @@ import { db, exitNodes } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { getCurrentExitNodeId } from "@server/lib/exitNodes"; import { getCurrentExitNodeId } from "@server/lib/exitNodes";
import { getTraefikConfig } from "#dynamic/lib/traefik"; 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 { sendToExitNode } from "#dynamic/lib/exitNodes";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -628,8 +628,7 @@ export class TraefikConfigManager {
.name, .name,
remoteRoleHeader: remoteRoleHeader:
config.getRawConfig().server.remote_headers config.getRawConfig().server.remote_headers.role
.role
} }
} }
}; };
+165
View File
@@ -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<string, string> } } {
return {
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
}
}
};
}
export function buildAiGatewayTrustMiddlewares(): Record<string, any> {
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<string, any> | 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<string, any>; services: Record<string, any> } {
const {
routerName,
serviceName,
rule,
ssl,
tls,
priority,
routerMiddlewares,
aiGatewayUrl,
redirectHttpsMiddlewareName
} = params;
const routers: Record<string, any> = {};
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 };
}
+399
View File
@@ -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<number, BrowserGatewayResourceEntry> {
const map = new Map<number, BrowserGatewayResourceEntry>();
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<number, BrowserGatewayResourceEntry>;
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<string, typeof bgResource.targets>();
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 } : {})
};
}
}
+44
View File
@@ -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 }] } : {})
};
}
+233 -531
View File
@@ -5,6 +5,7 @@ import {
aiProviders, aiProviders,
resourceAiProviders, resourceAiProviders,
siteResources, siteResources,
siteNetworks,
exitNodes exitNodes
} from "@server/db"; } from "@server/db";
import { import {
@@ -20,47 +21,47 @@ import {
} from "drizzle-orm"; } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { resources, sites, Target, targets } from "@server/db"; import { resources, sites, targets } from "@server/db";
import createPathRewriteMiddleware from "./middleware"; import { applyPathRewriteMiddleware } from "./middleware";
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils"; import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
import regionalCache from "@server/lib/cache"; import regionalCache from "@server/lib/cache";
import { TargetWithSite } from "./types";
import { buildWildcardTls } from "./certResolver";
import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule";
import { import {
AI_GATEWAY_TRUST_HEADER, buildHttpLoadBalancerServers,
AI_GATEWAY_RESOURCE_TYPE_HEADER, buildStickySessionCookie,
AI_GATEWAY_CLIENT_IP_HEADER, buildTcpUdpLoadBalancerServers,
getAiGatewayTrustToken buildStickySessionIp
} from "@server/lib/aiGatewayTrust"; } 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 redirectHttpsMiddlewareName = "redirect-to-https";
const badgerMiddlewareName = "badger"; 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( export async function getTraefikConfig(
exitNodeId: number, exitNodeId: number,
siteTypes: string[], siteTypes: string[],
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
allowRawResources = true, allowRawResources = true,
maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE maintenancePageUiUrl: string | null = null,
browserGatewayUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE browserGatewayUiUrl: string | null = null,
aiGatewayUrl: string | null = null aiGatewayUrl: string | null = null
): Promise<any> { ): Promise<any> {
// Get the exit node but cache it for 5 minutes to avoid hitting the DB too often // 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, headers: resources.headers,
proxyProtocol: resources.proxyProtocol, proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion, proxyProtocolVersion: resources.proxyProtocolVersion,
wildcard: resources.wildcard,
mode: resources.mode, mode: resources.mode,
maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType,
maintenanceTitle: resources.maintenanceTitle,
maintenanceMessage: resources.maintenanceMessage,
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
// Target fields // Target fields
targetId: targets.targetId, targetId: targets.targetId,
targetEnabled: targets.enabled, targetEnabled: targets.enabled,
@@ -146,8 +154,15 @@ export async function getTraefikConfig(
), ),
inArray(sites.type, siteTypes), inArray(sites.type, siteTypes),
allowRawResources allowRawResources
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three ? inArray(resources.mode, [
: eq(resources.mode, "http") "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 .orderBy(desc(targets.priority), targets.targetId); // stable ordering
@@ -156,6 +171,9 @@ export async function getTraefikConfig(
const resourcesMap = new Map(); const resourcesMap = new Map();
resourcesWithTargetsAndSites.forEach((row) => { resourcesWithTargetsAndSites.forEach((row) => {
if (!["http", "tcp", "udp"].includes(row.mode)) {
return;
}
const resourceId = row.resourceId; const resourceId = row.resourceId;
const resourceName = sanitize(row.resourceName) || ""; const resourceName = sanitize(row.resourceName) || "";
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b") 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 // Inference-mode resources have no targets/sites (their "backend" is the
// central AI gateway), so they can't be reached via the targets->sites // 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. // 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 // 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 {}; return {};
} }
@@ -319,56 +376,12 @@ export async function getTraefikConfig(
config_output.http.services = {}; config_output.http.services = {};
} }
const domainParts = fullDomain.split("."); const tls = buildWildcardTls({
let wildCard; fullDomain,
if (domainParts.length <= 2) { hasSubdomain: !!resource.subdomain,
wildCard = `*.${domainParts.join(".")}`; domainCertResolver: resource.domainCertResolver,
} else { preferWildcardCert: resource.preferWildcardCert
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 additionalMiddlewares = const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];
@@ -379,134 +392,40 @@ export async function getTraefikConfig(
]; ];
// Handle path rewriting middleware // Handle path rewriting middleware
if ( applyPathRewriteMiddleware(
resource.rewritePath !== null && config_output,
resource.path !== null && resource.resourceId,
resource.pathMatchType && key,
resource.rewritePathType resource.path,
) { resource.pathMatchType,
// Create a unique middleware name resource.rewritePath,
const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${key}`; 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}`
);
}
}
// Handle custom headers middleware // Handle custom headers middleware
if (resource.headers || resource.setHostHeader) { const customHeadersMiddleware = buildCustomHeadersMiddleware(
const headersObj: { [key: string]: string } = {}; resource.headers,
resource.setHostHeader,
if (resource.headers) { resource.resourceId
let headersArr: { name: string; value: string }[] = []; );
try { if (customHeadersMiddleware) {
headersArr = JSON.parse(resource.headers) as { if (!config_output.http.middlewares) {
name: string; config_output.http.middlewares = {};
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);
} }
config_output.http.middlewares[headersMiddlewareName] =
customHeadersMiddleware;
routerMiddlewares.push(headersMiddlewareName);
} }
// Build routing rules // Build routing rules
let rule = `Host(\`${fullDomain}\`)`; let rule = buildHostRule(fullDomain);
const priority = computeRoutePriority(
// priority logic resource.priority,
let priority: number; resource.path,
if (resource.priority && resource.priority != 100) { resource.pathMatchType
priority = resource.priority; );
} else { rule = appendPathMatch(rule, resource.path, resource.pathMatchType);
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
}
}
config_output.http.routers![routerName] = { config_output.http.routers![routerName] = {
entryPoints: [ entryPoints: [
@@ -535,90 +454,9 @@ export async function getTraefikConfig(
config_output.http.services![serviceName] = { config_output.http.services![serviceName] = {
loadBalancer: { loadBalancer: {
servers: (() => { servers: buildHttpLoadBalancerServers(targets),
// 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
)
);
})(),
...(resource.stickySession ...(resource.stickySession
? { ? buildStickySessionCookie(resource.ssl)
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
: {}) : {})
} }
}; };
@@ -668,77 +506,67 @@ export async function getTraefikConfig(
config_output[protocol].services[serviceName] = { config_output[protocol].services[serviceName] = {
loadBalancer: { loadBalancer: {
servers: (() => { servers: buildTcpUdpLoadBalancerServers(targets),
// 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}`
};
}
});
})(),
...(resource.proxyProtocol && protocol == "tcp" ...(resource.proxyProtocol && protocol == "tcp"
? { ? {
serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues? serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues?
} }
: {}), : {}),
...(resource.stickySession ...(resource.stickySession ? buildStickySessionIp() : {})
? {
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
: {})
} }
}; };
} }
} }
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<string>();
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) { if (aiGatewayUrl) {
// The AI gateway may live on a different host than the inference // The AI gateway may live on a different host than the inference
// resource itself (e.g. a remote exit node forwarding to the // 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 // recognize, so we pin the Host header to the gateway's own host
// and smuggle the original resource host through in "p-host" // and smuggle the original resource host through in "p-host"
// instead. // instead.
let aiGatewayHost: string | undefined; const aiGatewayHost = getAiGatewayHost(aiGatewayUrl);
try {
aiGatewayHost = new URL(aiGatewayUrl).host;
} catch {
aiGatewayHost = undefined;
}
// 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) { if (!config_output.http.middlewares) {
config_output.http.middlewares = {}; config_output.http.middlewares = {};
} }
config_output.http.middlewares[aiGatewayTrustMiddlewareNameResource] = { Object.assign(
headers: { config_output.http.middlewares,
customRequestHeaders: { buildAiGatewayTrustMiddlewares()
[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"
}
}
};
// Opt-in: a Badger instance with forward auth disabled, used only const aiGatewayClientIpMiddleware = buildAiGatewayClientIpMiddleware();
// to stamp the resolved client IP into a dedicated header before const enableAiGatewayClientIpHeader = !!aiGatewayClientIpMiddleware;
// the request reaches whatever sits between Traefik and the AI if (aiGatewayClientIpMiddleware) {
// gateway. Only the site-resource router below needs this - it's Object.assign(
// the only path that resolves request identity from the client IP config_output.http.middlewares,
// (see resolveRequestUser in aiGateway/pipeline.ts) - and it's the aiGatewayClientIpMiddleware
// 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
}
}
};
} }
// Public inference resources: same TLS/cert-resolver handling as // Public inference resources: same TLS/cert-resolver handling as
@@ -822,95 +609,41 @@ export async function getTraefikConfig(
const routerName = `${irKey}-router`; const routerName = `${irKey}-router`;
const serviceName = `${irKey}-service`; const serviceName = `${irKey}-service`;
let rule: string; const rule = buildHostRule(fullDomain, ir.wildcard);
if (ir.wildcard && fullDomain.startsWith("*.")) {
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
} else {
rule = `Host(\`${fullDomain}\`)`;
}
const domainParts = fullDomain.split("."); const tls = buildWildcardTls({
let wildCard; fullDomain,
if (domainParts.length <= 2) { hasSubdomain: !!ir.subdomain,
wildCard = `*.${domainParts.join(".")}`; domainCertResolver: ir.domainCertResolver,
} else { preferWildcardCert: ir.preferWildcardCert
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 irHeadersMiddlewareName = `${irKey}-headers-middleware`; const irHeadersMiddlewareName = `${irKey}-headers-middleware`;
if (!config_output.http.middlewares) { config_output.http.middlewares[irHeadersMiddlewareName] =
config_output.http.middlewares = {}; buildAiGatewayHostHeaderMiddleware(aiGatewayHost, fullDomain);
}
config_output.http.middlewares[irHeadersMiddlewareName] = {
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
}
}
};
const additionalMiddlewares = const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [ const routerMiddlewares = [
badgerMiddlewareName, badgerMiddlewareName,
aiGatewayTrustMiddlewareNameResource, AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
irHeadersMiddlewareName, irHeadersMiddlewareName,
...additionalMiddlewares ...additionalMiddlewares
]; ];
if (ir.ssl) { const { routers, services } = buildAiGatewayRouterAndService({
config_output.http.routers[routerName + "-redirect"] = { routerName,
entryPoints: [ serviceName,
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,
rule, rule,
ssl: ir.ssl,
tls,
priority: 100, priority: 100,
...(ir.ssl ? { tls } : {}) routerMiddlewares,
}; aiGatewayUrl,
redirectHttpsMiddlewareName
config_output.http.services[serviceName] = { });
loadBalancer: { Object.assign(config_output.http.routers, routers);
servers: [{ url: aiGatewayUrl }] Object.assign(config_output.http.services, services);
}
};
} }
// Private (siteResource) inference resources: routed by their alias // Private (siteResource) inference resources: routed by their alias
@@ -946,80 +679,49 @@ export async function getTraefikConfig(
const srKey = `inference-sr${sr.siteResourceId}`; const srKey = `inference-sr${sr.siteResourceId}`;
const routerName = `${srKey}-router`; const routerName = `${srKey}-router`;
const serviceName = `${srKey}-service`; 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("."); // siteResource aliases don't have a per-domain cert resolver
const wildCard = // stored, so always fall back to the global defaults.
domainParts.length <= 2 const tls = buildWildcardTls({
? `*.${domainParts.join(".")}` fullDomain,
: `*.${domainParts.slice(1).join(".")}`; hasSubdomain: true
});
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const tls = {
certResolver: globalDefaultResolver,
...(globalDefaultPreferWildcard
? { domains: [{ main: wildCard }] }
: {})
};
const srHeadersMiddlewareName = `${srKey}-headers-middleware`; const srHeadersMiddlewareName = `${srKey}-headers-middleware`;
if (!config_output.http.middlewares) { if (!config_output.http.middlewares) {
config_output.http.middlewares = {}; config_output.http.middlewares = {};
} }
config_output.http.middlewares[srHeadersMiddlewareName] = { config_output.http.middlewares[srHeadersMiddlewareName] =
headers: { buildAiGatewayHostHeaderMiddleware(
customRequestHeaders: { aiGatewayHost,
...(aiGatewayHost ? { Host: aiGatewayHost } : {}), fullDomain
"p-host": fullDomain );
}
}
};
const additionalMiddlewares = const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [ const routerMiddlewares = [
...(enableAiGatewayClientIpHeader ...(enableAiGatewayClientIpHeader
? [aiGatewayClientIpMiddlewareName] ? [AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME]
: []), : []),
aiGatewayTrustMiddlewareNameSiteResource, AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
srHeadersMiddlewareName, srHeadersMiddlewareName,
...additionalMiddlewares ...additionalMiddlewares
]; ];
if (sr.ssl) { const { routers, services } = buildAiGatewayRouterAndService({
config_output.http.routers[routerName + "-redirect"] = { routerName,
entryPoints: [ serviceName,
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,
rule, 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. 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 } : {}) routerMiddlewares,
}; aiGatewayUrl,
redirectHttpsMiddlewareName
config_output.http.services[serviceName] = { });
loadBalancer: { Object.assign(config_output.http.routers, routers);
servers: [{ url: aiGatewayUrl }] Object.assign(config_output.http.services, services);
}
};
} }
} }
} }
+46
View File
@@ -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
}
};
}
+134
View File
@@ -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
}
}
};
}
+59
View File
@@ -1,5 +1,64 @@
import logger from "@server/logger"; 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( export default function createPathRewriteMiddleware(
middlewareName: string, middlewareName: string,
path: string, path: string,
+71
View File
@@ -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;
}
+114
View File
@@ -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<string>;
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
};
}
}
+21
View File
@@ -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;
};
};
+1
View File
@@ -38,3 +38,4 @@ export * from "./logActionAudit";
export * from "./verifyOlmAccess"; export * from "./verifyOlmAccess";
export * from "./verifyLimits"; export * from "./verifyLimits";
export * from "./verifyResourcePolicyAccess"; export * from "./verifyResourcePolicyAccess";
export * from "./verifyCertificateAccess";
@@ -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 { Request, Response, NextFunction } from "express";
import { db, domainNamespaces } from "@server/db"; import { db, domainNamespaces } from "@server/db";
import { certificates } from "@server/db"; import { certificates } from "@server/db";
-888
View File
@@ -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<void> {
// 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<string | null> {
// 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<void> {
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<void> {
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<void> {
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<string>([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);
}
-240
View File
@@ -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<string>,
useCache: boolean = true
): Promise<Array<CertificateResult>> {
const finalResults: CertificateResult[] = [];
const domainsToQuery = new Set<string>();
// 1. Check cache first if enabled
if (useCache) {
for (const domain of domains) {
const cacheKey = `cert:${domain}`;
const cachedCert = await cache.get<CertificateResult>(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<CertificateResult>(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<string>();
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<string, (typeof potentialCerts)[0]>();
const wildcardMatches = new Map<string, (typeof potentialCerts)[0]>();
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;
}
+37
View File
@@ -19,6 +19,9 @@ import {
privateConfigSchema, privateConfigSchema,
readPrivateConfigFile readPrivateConfigFile
} from "#private/lib/readConfigFile"; } 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 { export class PrivateConfig {
private rawPrivateConfig!: z.infer<typeof privateConfigSchema>; private rawPrivateConfig!: z.infer<typeof privateConfigSchema>;
@@ -45,6 +48,8 @@ export class PrivateConfig {
this.rawPrivateConfig = parsedPrivateConfig; this.rawPrivateConfig = parsedPrivateConfig;
this.migrateDeprecatedAcmeConfig(privateEnvironment);
process.env.BRANDING_HIDE_AUTH_LAYOUT_FOOTER = process.env.BRANDING_HIDE_AUTH_LAYOUT_FOOTER =
this.rawPrivateConfig.branding?.hide_auth_layout_footer === true this.rawPrivateConfig.branding?.hide_auth_layout_footer === true
? "true" ? "true"
@@ -146,6 +151,38 @@ export class PrivateConfig {
public getRawPrivateConfig() { public getRawPrivateConfig() {
return this.rawPrivateConfig; 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(); export const privateConfig = new PrivateConfig();
+9
View File
@@ -109,6 +109,11 @@ export const privateConfigSchema = z
enable_redis: z.boolean().optional().default(false), enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional(), 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), enable_acme_cert_sync: z.boolean().optional().default(true),
disable_private_http_placeholder: z disable_private_http_placeholder: z
.boolean() .boolean()
@@ -117,6 +122,10 @@ export const privateConfigSchema = z
}) })
.optional() .optional()
.prefault({}), .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 acme: z
.object({ .object({
acme_json_path: z acme_json_path: z
File diff suppressed because it is too large Load Diff
+53 -33
View File
@@ -104,11 +104,18 @@ LQIDAQAB
} }
public async forceRecheck() { public async forceRecheck() {
this.statusCache.flushAll();
this.licenseKeyCache.flushAll();
this.phoneHomeFailureCount = 0; 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<boolean> { public async isUnlocked(): Promise<boolean> {
@@ -181,10 +188,15 @@ LQIDAQAB
} }
let foundHostKey = false; 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 // Validate stored license keys
for (const key of allKeysRes) { for (const key of allKeysRes) {
try { try {
// Decrypt the license key and token // Decrypt the license key, token, and instance ID
const decryptedKey = decrypt( const decryptedKey = decrypt(
key.licenseKeyId, key.licenseKeyId,
this.serverSecret this.serverSecret
@@ -193,6 +205,10 @@ LQIDAQAB
key.token, key.token,
this.serverSecret this.serverSecret
); );
const decryptedInstanceId = decrypt(
key.instanceId,
this.serverSecret
);
const payload = validateJWT<TokenPayload>( const payload = validateJWT<TokenPayload>(
decryptedToken, decryptedToken,
@@ -214,6 +230,11 @@ LQIDAQAB
if (payload.type === "host") { if (payload.type === "host") {
foundHostKey = true; foundHostKey = true;
} }
keys.push({
licenseKey: decryptedKey,
instanceId: decryptedInstanceId
});
} catch (e) { } catch (e) {
logger.error( logger.error(
`Error validating license key: ${key.licenseKeyId}` `Error validating license key: ${key.licenseKeyId}`
@@ -233,37 +254,36 @@ LQIDAQAB
status.isHostLicensed = false; status.isHostLicensed = false;
} }
const keys = allKeysRes.map((key) => ({
licenseKey: decrypt(key.licenseKeyId, this.serverSecret),
instanceId: decrypt(key.instanceId, this.serverSecret)
}));
let apiResponse: ValidateLicenseAPIResponse | undefined; let apiResponse: ValidateLicenseAPIResponse | undefined;
try { if (keys.length > 0) {
// Phone home to validate license keys try {
apiResponse = await this.phoneHome(keys, false); // Phone home to validate license keys
apiResponse = await this.phoneHome(keys, false);
if (!apiResponse?.success) { if (!apiResponse?.success) {
throw new Error(apiResponse?.error); throw new Error(apiResponse?.error);
} }
// Reset failure count on success // Reset failure count on success
this.phoneHomeFailureCount = 0; this.phoneHomeFailureCount = 0;
} catch (e) { } catch (e) {
this.phoneHomeFailureCount++; this.phoneHomeFailureCount++;
if (this.phoneHomeFailureCount === 1) { if (this.phoneHomeFailureCount === 1) {
// First failure: fail silently // First failure: fail silently
logger.error("Error communicating with license server:"); logger.error(
logger.error(e); "Error communicating with license server:"
logger.error( );
`Allowing failure. Will retry one more time at next run interval.` logger.error(e);
); logger.error(
// return last known good status `Allowing failure. Will retry one more time at next run interval.`
return this.statusCache.get( );
this.statusKey // return last known good status
) as LicenseStatus; return this.statusCache.get(
} else { this.statusKey
// Subsequent failures: fail abruptly ) as LicenseStatus;
throw e; } else {
// Subsequent failures: fail abruptly
throw e;
}
} }
} }
-1
View File
@@ -11,7 +11,6 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
export * from "./verifyCertificateAccess";
export * from "./verifyRemoteExitNodeAccess"; export * from "./verifyRemoteExitNodeAccess";
export * from "./verifyIdpAccess"; export * from "./verifyIdpAccess";
export * from "./verifyLoginPageAccess"; export * from "./verifyLoginPageAccess";
@@ -295,8 +295,8 @@ async function disableFeature(
await disableRotateCredentials(orgId); await disableRotateCredentials(orgId);
break; break;
case TierFeature.MaintencePage: case TierFeature.MaintenancePage:
await disableMaintencePage(orgId); await disablemaintenancePage(orgId);
break; break;
case TierFeature.DevicePosture: case TierFeature.DevicePosture:
@@ -319,10 +319,6 @@ async function disableFeature(
await disableAutoProvisioning(orgId); await disableAutoProvisioning(orgId);
break; break;
case TierFeature.AdvancedPrivateResources:
await disableAdvancedPrivateResources(orgId);
break;
case TierFeature.FullRbac: case TierFeature.FullRbac:
await disableFullRbac(orgId); await disableFullRbac(orgId);
break; break;
@@ -368,13 +364,6 @@ async function disableDeviceApprovals(orgId: string): Promise<void> {
logger.info(`Disabled device approvals on all roles for org ${orgId}`); logger.info(`Disabled device approvals on all roles for org ${orgId}`);
} }
async function disableAdvancedPrivateResources(orgId: string): Promise<void> {
// 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<void> { async function disableFullRbac(orgId: string): Promise<void> {
logger.info(`Disabled full RBAC for org ${orgId}`); logger.info(`Disabled full RBAC for org ${orgId}`);
} }
@@ -506,7 +495,7 @@ async function disableConnectionLogs(orgId: string): Promise<void> {
async function disableRotateCredentials(orgId: string): Promise<void> {} async function disableRotateCredentials(orgId: string): Promise<void> {}
async function disableMaintencePage(orgId: string): Promise<void> { async function disablemaintenancePage(orgId: string): Promise<void> {
await db await db
.update(resources) .update(resources)
.set({ .set({
@@ -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";
@@ -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)
});
}
@@ -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";
+1 -55
View File
@@ -11,7 +11,6 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import * as certificates from "#private/routers/certificates";
import { createStore } from "#private/lib/rateLimitStore"; import { createStore } from "#private/lib/rateLimitStore";
import * as billing from "#private/routers/billing"; import * as billing from "#private/routers/billing";
import * as remoteExitNode from "#private/routers/remoteExitNode"; 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 domain from "#private/routers/domain";
import * as auth from "#private/routers/auth"; import * as auth from "#private/routers/auth";
import * as license from "#private/routers/license"; 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 logs from "#private/routers/auditLogs";
import * as misc from "#private/routers/misc"; import * as misc from "#private/routers/misc";
import * as reKey from "#private/routers/re-key"; import * as reKey from "#private/routers/re-key";
import * as approval from "#private/routers/approvals"; import * as approval from "#private/routers/approvals";
import * as ssh from "#private/routers/ssh";
import * as user from "#private/routers/user"; import * as user from "#private/routers/user";
import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule"; import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks"; 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 * as policy from "#private/routers/policy";
import { import {
@@ -53,7 +49,6 @@ import {
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import { import {
logActionAudit, logActionAudit,
verifyCertificateAccess,
verifyIdpAccess, verifyIdpAccess,
verifyLoginPageAccess, verifyLoginPageAccess,
verifyRemoteExitNodeAccess, verifyRemoteExitNodeAccess,
@@ -167,32 +162,6 @@ authenticated.get(
orgIdp.listUserAdminOrgIdps 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") { if (build === "saas") {
authenticated.post( authenticated.post(
"/org/:orgId/billing/create-checkout-session", "/org/:orgId/billing/create-checkout-session",
@@ -652,17 +621,6 @@ authenticated.put(
reKey.reGenerateExitNodeSecret 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( authenticated.post(
"/user/:userId/add-role/:roleId", "/user/:userId/add-role/:roleId",
verifyRoleAccess, verifyRoleAccess,
@@ -868,18 +826,6 @@ authenticated.get(
healthChecks.getBatchedHealthCheckStatusHistory healthChecks.getBatchedHealthCheckStatusHistory
); );
authenticated.get(
"/client/:clientId/verify-associations-cache",
verifyClientAccess,
client.verifyClientAssociationsCache
);
authenticated.post(
"/client/:clientId/rebuild-associations-cache",
verifyClientAccess,
client.rebuildClientAssociationsCacheRoute
);
authenticated.post( authenticated.post(
"/org/:orgId/logs/access/attempt", "/org/:orgId/logs/access/attempt",
verifyOrgAccess, verifyOrgAccess,
+1 -1
View File
@@ -15,7 +15,7 @@ import * as orgIdp from "#private/routers/orgIdp";
import * as org from "#private/routers/org"; import * as org from "#private/routers/org";
import * as logs from "#private/routers/auditLogs"; import * as logs from "#private/routers/auditLogs";
import * as alertEvents from "#private/routers/alertEvents"; 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 siteProvisioning from "#private/routers/siteProvisioning";
import * as policy from "#private/routers/policy"; import * as policy from "#private/routers/policy";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
+1 -21
View File
@@ -17,14 +17,8 @@ import * as orgIdp from "#private/routers/orgIdp";
import * as billing from "#private/routers/billing"; import * as billing from "#private/routers/billing";
import * as license from "#private/routers/license"; import * as license from "#private/routers/license";
import * as resource from "#private/routers/resource"; 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 { import { verifySessionUserMiddleware } from "@server/middlewares";
verifySessionUserMiddleware,
verifyUserFromResourceSessionMiddleware
} from "@server/middlewares";
import { internalRouter as ir } from "@server/routers/internal"; import { internalRouter as ir } from "@server/routers/internal";
@@ -46,17 +40,3 @@ internalRouter.post(
internalRouter.get(`/license/status`, license.getLicenseStatus); internalRouter.get(`/license/status`, license.getLicenseStatus);
internalRouter.get("/maintenance/info", resource.getMaintenanceInfo); 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);
@@ -29,7 +29,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { validateAndConstructDomain } from "@server/lib/domainUtils"; 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"; import { CreateLoginPageResponse } from "@server/routers/loginPage/types";
@@ -22,7 +22,7 @@ import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { subdomainSchema } from "@server/lib/schemas"; 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"; import { UpdateLoginPageResponse } from "@server/routers/loginPage/types";
@@ -85,7 +85,6 @@ export async function updateLoginPage(
const { loginPageId, orgId } = parsedParams.data; const { loginPageId, orgId } = parsedParams.data;
const [existingLoginPage] = await db const [existingLoginPage] = await db
.select() .select()
.from(loginPage) .from(loginPage)
@@ -16,7 +16,7 @@ import { db, exitNodes, newts, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import redisManager from "#private/lib/redis"; 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 INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
@@ -150,47 +150,47 @@ async function processPendingReconnects(): Promise<void> {
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.` `Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
); );
await sendReconnectToNewts(exitNodeId); // await sendReconnectToNewts(exitNodeId);
await removePending(exitNodeId); await removePending(exitNodeId);
} }
} }
async function sendReconnectToNewts(exitNodeId: number): Promise<void> { // async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
try { // try {
const connectedNewts = await db // const connectedNewts = await db
.select({ newtId: newts.newtId }) // .select({ newtId: newts.newtId })
.from(newts) // .from(newts)
.innerJoin(sites, eq(newts.siteId, sites.siteId)) // .innerJoin(sites, eq(newts.siteId, sites.siteId))
.where(eq(sites.exitNodeId, exitNodeId)); // .where(eq(sites.exitNodeId, exitNodeId));
if (connectedNewts.length === 0) { // if (connectedNewts.length === 0) {
logger.debug( // logger.debug(
`No newts found for exit node ${exitNodeId}, nothing to reconnect` // `No newts found for exit node ${exitNodeId}, nothing to reconnect`
); // );
return; // return;
} // }
logger.info( // logger.info(
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}` // `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
); // );
const reconnectMessage = { // const reconnectMessage = {
type: "newt/wg/reconnect", // type: "newt/wg/reconnect",
data: {} // data: {}
}; // };
await Promise.allSettled( // await Promise.allSettled(
connectedNewts.map(({ newtId }) => // connectedNewts.map(({ newtId }) =>
sendToClient(newtId, reconnectMessage) // sendToClient(newtId, reconnectMessage)
) // )
); // );
} catch (error) { // } catch (error) {
logger.error( // logger.error(
`Failed to send reconnect messages for exit node ${exitNodeId}`, // `Failed to send reconnect messages for exit node ${exitNodeId}`,
{ error } // { error }
); // );
} // }
} // }
async function removePending(exitNodeId: number): Promise<void> { async function removePending(exitNodeId: number): Promise<void> {
pendingReconnects.delete(exitNodeId); pendingReconnects.delete(exitNodeId);
-14
View File
@@ -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";
+6 -11
View File
@@ -13,22 +13,17 @@
import { import {
handleRemoteExitNodeRegisterMessage, handleRemoteExitNodeRegisterMessage,
handleRemoteExitNodePingMessage, handleRemoteExitNodePingMessage
startRemoteExitNodeOfflineChecker,
startExitNodeReconnectScheduler
} from "#private/routers/remoteExitNode"; } from "#private/routers/remoteExitNode";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { build } from "@server/build"; import {
import { handleConnectionLogMessage, handleRequestLogMessage } from "#private/routers/newt"; handleConnectionLogMessage,
handleRequestLogMessage
} from "#private/routers/newt";
export const messageHandlers: Record<string, MessageHandler> = { export const messageHandlers: Record<string, MessageHandler> = {
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
"remoteExitNode/ping": handleRemoteExitNodePingMessage, "remoteExitNode/ping": handleRemoteExitNodePingMessage,
"newt/access-log": handleConnectionLogMessage, "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
}
+25
View File
@@ -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();
}
@@ -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 { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, resources, targets } from "@server/db"; import { db, resources, targets } from "@server/db";
@@ -1 +1,2 @@
export * from "./types"; export * from "./types";
export * from "./getBrowserTarget";
@@ -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( export async function createCertificate(
domainId: string, domainId: string,
domain: string, domain: string,
trx: Transaction | typeof db 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)
});
} }
@@ -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 { certificates, db, domainNamespaces, domains, orgDomains } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -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 { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { certificates, db, domains } from "@server/db"; import { certificates, db, domains } from "@server/db";
+5
View File
@@ -0,0 +1,5 @@
export * from "./getCertificate";
export * from "./restartCertificate";
export * from "./syncCertToNewts";
export * from "./getBatchedCertificates";
export * from "./createCertificate";
@@ -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 { certificates, db } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -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 { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { pushCertUpdateToAffectedNewts } from "#private/lib/acmeCertSync"; import { pushCertUpdateToAffectedNewts } from "@server/lib/acmeCertSync";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
+51 -2
View File
@@ -20,6 +20,7 @@ import * as logs from "./auditLogs";
import * as launcher from "./launcher"; import * as launcher from "./launcher";
import * as newt from "./newt"; import * as newt from "./newt";
import * as olm from "./olm"; import * as olm from "./olm";
import * as ssh from "./ssh";
import * as serverInfo from "./serverInfo"; import * as serverInfo from "./serverInfo";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { import {
@@ -49,19 +50,21 @@ import {
verifyAiProviderAccess, verifyAiProviderAccess,
verifyAiModelAccess, verifyAiModelAccess,
verifyAiBudgetAccess, verifyAiBudgetAccess,
verifyVirtualApiKeyAccess verifyVirtualApiKeyAccess,
logActionAudit,
verifyCertificateAccess
} from "@server/middlewares"; } from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { build } from "@server/build"; import { build } from "@server/build";
import { createStore } from "#dynamic/lib/rateLimitStore"; import { createStore } from "#dynamic/lib/rateLimitStore";
import { logActionAudit } from "#dynamic/middlewares";
import { checkRoundTripMessage } from "./ws"; import { checkRoundTripMessage } from "./ws";
import * as labels from "@server/routers/labels"; import * as labels from "@server/routers/labels";
import * as aiProvider from "@server/routers/aiProvider"; import * as aiProvider from "@server/routers/aiProvider";
import * as aiBudget from "@server/routers/aiBudget"; import * as aiBudget from "@server/routers/aiBudget";
import * as virtualApiKey from "@server/routers/virtualApiKey"; import * as virtualApiKey from "@server/routers/virtualApiKey";
import * as certificates from "@server/routers/certificates";
// Root routes // Root routes
export const unauthenticated = Router(); export const unauthenticated = Router();
@@ -1863,6 +1866,52 @@ authenticated.put(
labels.detachLabelFromItem 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 // Auth routes
export const authRouter = Router(); export const authRouter = Router();
unauthenticated.use("/auth", authRouter); unauthenticated.use("/auth", authRouter);
+20 -3
View File
@@ -1,16 +1,20 @@
import { Router } from "express"; import { Router } from "express";
import * as gerbil from "@server/routers/gerbil"; import * as gerbil from "@server/routers/gerbil";
import * as traefik from "@server/routers/traefik"; import * as traefik from "@server/routers/traefik";
import * as resource from "./resource"; import * as resource from "@server/routers/resource";
import * as badger from "./badger"; import * as badger from "@server/routers/badger";
import * as auth from "@server/routers/auth"; import * as auth from "@server/routers/auth";
import * as supporterKey from "@server/routers/supporterKey"; import * as supporterKey from "@server/routers/supporterKey";
import * as idp from "@server/routers/idp"; import * as idp from "@server/routers/idp";
import * as ssh from "@server/routers/ssh";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { import {
verifyResourceAccess, verifyResourceAccess,
verifySessionUserMiddleware verifySessionUserMiddleware,
verifyUserFromResourceSessionMiddleware
} from "@server/middlewares"; } from "@server/middlewares";
import * as ws from "@server/routers/ws";
import * as browserTarget from "@server/routers/browserGatewayTarget";
// Root routes // Root routes
export const internalRouter = Router(); export const internalRouter = Router();
@@ -42,6 +46,12 @@ internalRouter.get("/idp", idp.listIdps);
internalRouter.get("/idp/:idpId", idp.getIdp); internalRouter.get("/idp/:idpId", idp.getIdp);
internalRouter.post(
"/org/:orgId/ssh/sign-key",
verifyUserFromResourceSessionMiddleware,
ssh.signSshKey
);
// Gerbil routes // Gerbil routes
const gerbilRouter = Router(); const gerbilRouter = Router();
internalRouter.use("/gerbil", gerbilRouter); internalRouter.use("/gerbil", gerbilRouter);
@@ -64,3 +74,10 @@ badgerRouter.post("/verify-session", badger.verifyResourceSession);
badgerRouter.post("/exchange-session", badger.exchangeSession); badgerRouter.post("/exchange-session", badger.exchangeSession);
internalRouter.get("/resource/browser-target", browserTarget.getBrowserTarget);
internalRouter.get(
"/ws/round-trip-message/:messageId",
verifyUserFromResourceSessionMiddleware,
ws.checkRoundTripMessage
);
-1
View File
@@ -33,7 +33,6 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor
import { doCidrsOverlap } from "@server/lib/ip"; import { doCidrsOverlap } from "@server/lib/ip";
import { generateCA } from "@server/lib/sshCA"; import { generateCA } from "@server/lib/sshCA";
import { encrypt } from "@server/lib/crypto"; import { encrypt } from "@server/lib/crypto";
import { generateId } from "@server/auth/sessions/app";
const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/; const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/;
+3 -20
View File
@@ -24,14 +24,14 @@ import logger from "@server/logger";
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas"; import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "@server/routers/certificates";
import { import {
validateAndConstructDomain, validateAndConstructDomain,
checkWildcardDomainConflict checkWildcardDomainConflict
} from "@server/lib/domainUtils"; } from "@server/lib/domainUtils";
import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { import {
getUniqueResourceName, getUniqueResourceName,
getUniqueResourcePolicyName 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 // Validate domain and construct full domain
const domainResult = await validateAndConstructDomain( const domainResult = await validateAndConstructDomain(
domainId, domainId,
@@ -647,9 +632,7 @@ async function createHttpResource(
); );
} }
if (build !== "oss") { await createCertificate(domainId, fullDomain, db);
await createCertificate(domainId, fullDomain, db);
}
return response<CreateResourceResponse>(res, { return response<CreateResourceResponse>(res, {
data: resource, data: resource,
+2 -4
View File
@@ -38,7 +38,7 @@ import {
} from "@server/lib/schemas"; } from "@server/lib/schemas";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "@server/routers/certificates/createCertificate";
import { import {
validateAndConstructDomain, validateAndConstructDomain,
checkWildcardDomainConflict checkWildcardDomainConflict
@@ -678,9 +678,7 @@ async function updateHttpResource(
// Update the subdomain in the update data // Update the subdomain in the update data
updateData.subdomain = finalSubdomain; updateData.subdomain = finalSubdomain;
if (build != "oss") { await createCertificate(domainId, fullDomain, db);
await createCertificate(domainId, fullDomain, db);
}
} }
let headers = undefined; let headers = undefined;
+1 -1
View File
@@ -135,7 +135,7 @@ export async function createRole(
const isLicensedSshPam = await isLicensedOrSubscribed( const isLicensedSshPam = await isLicensedOrSubscribed(
orgId, orgId,
tierMatrix.advancedPrivateResources tierMatrix.roleBasedSSHControls
); );
const roleInsertValues: Record<string, unknown> = { const roleInsertValues: Record<string, unknown> = {
name: roleData.name, name: roleData.name,
+1 -1
View File
@@ -144,7 +144,7 @@ export async function updateRole(
const isLicensedSshPam = await isLicensedOrSubscribed( const isLicensedSshPam = await isLicensedOrSubscribed(
orgId, orgId,
tierMatrix.advancedPrivateResources tierMatrix.roleBasedSSHControls
); );
if (!isLicensedSshPam) { if (!isLicensedSshPam) {
delete updateData.sshSudoMode; delete updateData.sshSudoMode;
@@ -10,8 +10,7 @@ import {
SiteResource, SiteResource,
siteResources, siteResources,
sites, sites,
userSiteResources, userSiteResources
primaryDb
} from "@server/db"; } from "@server/db";
import { getUniqueSiteResourceName } from "@server/db/names"; import { getUniqueSiteResourceName } from "@server/db/names";
import { import {
@@ -19,8 +18,6 @@ import {
isIpInCidr, isIpInCidr,
portRangeStringSchema portRangeStringSchema
} from "@server/lib/ip"; } from "@server/lib/ip";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { import {
rebuildClientAssociationsFromSiteResource, rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited isOrgRebuildRateLimited
@@ -35,7 +32,7 @@ import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { validateAndConstructDomain } from "@server/lib/domainUtils"; 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 { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { LimitId } from "@server/lib/billing"; 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 // Verify the site exists and belongs to the org
const sitesToAssign = await db const sitesToAssign = await db
.select() .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; let updatedNiceId = niceId;
if (!niceId) { if (!niceId) {
updatedNiceId = await getUniqueSiteResourceName(orgId); updatedNiceId = await getUniqueSiteResourceName(orgId);
@@ -646,13 +614,13 @@ export async function createSiteResource(
fullDomain, fullDomain,
requiresExitNodeConnection: mode === "inference" // in the future we might want to have different modes that do this requiresExitNodeConnection: mode === "inference" // in the future we might want to have different modes that do this
}; };
if (isLicensedSshPam) {
if (authDaemonPort !== undefined) if (authDaemonPort !== undefined)
insertValues.authDaemonPort = authDaemonPort; insertValues.authDaemonPort = authDaemonPort;
if (authDaemonMode !== undefined) if (authDaemonMode !== undefined)
insertValues.authDaemonMode = authDaemonMode; insertValues.authDaemonMode = authDaemonMode;
if (pamMode !== undefined) insertValues.pamMode = pamMode; if (pamMode !== undefined) insertValues.pamMode = pamMode;
}
[newSiteResource] = await trx [newSiteResource] = await trx
.insert(siteResources) .insert(siteResources)
.values(insertValues) .values(insertValues)
@@ -771,8 +739,7 @@ export async function createSiteResource(
ssl && ssl &&
(mode === "http" || mode == "inference") && (mode === "http" || mode == "inference") &&
domainId && domainId &&
fullDomain && fullDomain
build != "oss"
) { ) {
await createCertificate(domainId, fullDomain, db); await createCertificate(domainId, fullDomain, db);
} }
@@ -10,8 +10,6 @@ import {
sites, sites,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { validateAndConstructDomain } from "@server/lib/domainUtils";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { eq, and, ne, inArray } from "drizzle-orm"; 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 const [org] = await db
.select() .select()
.from(orgs) .from(orgs)
@@ -541,10 +519,9 @@ export async function updateSiteResource(
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// Update the site resource // Update the site resource
const sshPamSet = const sshPamSet =
isLicensedSshPam && authDaemonPort !== undefined ||
(authDaemonPort !== undefined || authDaemonMode !== undefined ||
authDaemonMode !== undefined || pamMode !== undefined
pamMode !== undefined)
? { ? {
...(authDaemonPort !== undefined && { ...(authDaemonPort !== undefined && {
authDaemonPort authDaemonPort
@@ -741,8 +718,7 @@ export async function updateSiteResource(
ssl && ssl &&
(mode === "http" || mode == "inference") && (mode === "http" || mode == "inference") &&
domainId && domainId &&
fullDomain && fullDomain
build != "oss"
) { ) {
await createCertificate(domainId, fullDomain, db); await createCertificate(domainId, fullDomain, db);
} }
+1
View File
@@ -0,0 +1 @@
export * from "./signSshKey";
@@ -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 { Request, Response, NextFunction } from "express";
import { randomInt } from "crypto"; import { randomInt } from "crypto";
import { z } from "zod"; import { z } from "zod";
@@ -34,9 +21,7 @@ import {
Resource, Resource,
SiteResource SiteResource
} from "@server/db"; } from "@server/db";
import { logAccessAudit } from "#private/lib/logAccessAudit"; import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -47,7 +32,7 @@ import { canUserAccessResource } from "@server/auth/canUserAccessResource";
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource"; import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA"; import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
import config from "@server/lib/config"; 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 { ActionsEnum } from "@server/auth/actions";
import type { SignSshKeyResponse } from "@server/routers/ssh/types"; 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 // Get and decrypt the org's CA keys
const caKeys = await getOrgCAKeys( const caKeys = await getOrgCAKeys(
orgId, orgId,
-13
View File
@@ -1,4 +1,3 @@
import { build } from "@server/build";
import { import {
handleNewtRegisterMessage, handleNewtRegisterMessage,
handleReceiveBandwidthMessage, handleReceiveBandwidthMessage,
@@ -8,15 +7,12 @@ import {
handleNewtExitNodesRequestMessage, handleNewtExitNodesRequestMessage,
handleApplyBlueprintMessage, handleApplyBlueprintMessage,
handleNewtPingMessage, handleNewtPingMessage,
startNewtOfflineChecker,
handleNewtDisconnectingMessage handleNewtDisconnectingMessage
} from "../newt"; } from "../newt";
import { startPingAccumulator } from "../newt/pingAccumulator";
import { import {
handleOlmRegisterMessage, handleOlmRegisterMessage,
handleOlmRelayMessage, handleOlmRelayMessage,
handleOlmPingMessage, handleOlmPingMessage,
startOlmOfflineChecker,
handleOlmServerPeerAddMessage, handleOlmServerPeerAddMessage,
handleOlmUnRelayMessage, handleOlmUnRelayMessage,
handleOlmDisconnectingMessage, handleOlmDisconnectingMessage,
@@ -52,12 +48,3 @@ export const messageHandlers: Record<string, MessageHandler> = {
"newt/healthcheck/status": handleHealthcheckStatusMessage, "newt/healthcheck/status": handleHealthcheckStatusMessage,
"ws/round-trip/complete": handleRoundTripMessage "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
}
+25
View File
@@ -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();
}
@@ -35,10 +35,6 @@ import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceHttpPage() { export default function PrivateResourceHttpPage() {
const t = useTranslations(); const t = useTranslations();
const { save, siteResource } = useSaveSiteResource(); const { save, siteResource } = useSaveSiteResource();
const { isPaidUser } = usePaidStatus();
const httpSectionDisabled = !isPaidUser(
tierMatrix.advancedPrivateResources
);
const [selectedSites, setSelectedSites] = useState(() => const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource) buildSelectedSitesForResource(siteResource)
); );
@@ -120,7 +116,7 @@ export default function PrivateResourceHttpPage() {
)} )}
orgId={siteResource.orgId} orgId={siteResource.orgId}
watch={asAnyWatch(form.watch)} watch={asAnyWatch(form.watch)}
disabled={httpSectionDisabled} disabled={false}
siteResourceId={siteResource.id} siteResourceId={siteResource.id}
/> />
</SettingsFormCell> </SettingsFormCell>
@@ -135,7 +131,6 @@ export default function PrivateResourceHttpPage() {
type="submit" type="submit"
form="private-resource-http-form" form="private-resource-http-form"
loading={saveLoading} loading={saveLoading}
disabled={httpSectionDisabled}
> >
{t("saveSettings")} {t("saveSettings")}
</Button> </Button>
@@ -12,16 +12,13 @@ import {
SettingsFormGrid SettingsFormGrid
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields"; import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form"; import { Form } from "@app/components/ui/form";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { import {
createSshFormSchema, createSshFormSchema,
inferSshPamMode inferSshPamMode
} from "@app/lib/privateResourceForm"; } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react"; import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -39,8 +36,6 @@ import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceSshPage() { export default function PrivateResourceSshPage() {
const t = useTranslations(); const t = useTranslations();
const { save, siteResource } = useSaveSiteResource(); const { save, siteResource } = useSaveSiteResource();
const { isPaidUser } = usePaidStatus();
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const isNative = siteResource.authDaemonMode === "native"; const isNative = siteResource.authDaemonMode === "native";
const [sshServerMode] = useState<"standard" | "native">( const [sshServerMode] = useState<"standard" | "native">(
isNative ? "native" : "standard" isNative ? "native" : "standard"
@@ -150,7 +145,6 @@ export default function PrivateResourceSshPage() {
return ( return (
<SettingsContainer> <SettingsContainer>
<PaidFeaturesAlert tiers={tierMatrix.advancedPrivateResources} />
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -161,68 +155,56 @@ export default function PrivateResourceSshPage() {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<fieldset <Form {...form}>
disabled={sshSectionDisabled} <SettingsSectionBody>
className={ <SettingsSectionForm variant="half">
sshSectionDisabled <SettingsFormGrid>
? "opacity-50 pointer-events-none" <SshServerSettingsFields
: "" idPrefix="private-ssh-edit"
} pamMode={pamMode}
> standardDaemonLocation={
<Form {...form}> standardDaemonLocation
<SettingsSectionBody> }
<SettingsSectionForm variant="half"> authDaemonPort={authDaemonPort}
<SettingsFormGrid> onPamModeChange={handlePamModeChange}
<SshServerSettingsFields onStandardDaemonLocationChange={
idPrefix="private-ssh-edit" handleDaemonLocationChange
pamMode={pamMode} }
standardDaemonLocation={ onAuthDaemonPortChange={(value) =>
standardDaemonLocation form.setValue("authDaemonPort", value, {
} shouldValidate: true
authDaemonPort={authDaemonPort} })
onPamModeChange={handlePamModeChange} }
onStandardDaemonLocationChange={ authDaemonPortError={
handleDaemonLocationChange form.formState.errors.authDaemonPort
} ?.message
onAuthDaemonPortChange={(value) => }
form.setValue( sshServerMode={sshServerMode}
"authDaemonPort", serverModeDisplay="badge"
value, />
{ shouldValidate: true } <PrivateResourceSshFields
) control={asAnyControl(form.control)}
} setValue={asAnySetValue(form.setValue)}
authDaemonPortError={ watch={asAnyWatch(form.watch)}
form.formState.errors.authDaemonPort orgId={siteResource.orgId}
?.message selectedSites={selectedSites}
} onSelectedSitesChange={setSelectedSites}
sshServerMode={sshServerMode} showSshSettings={false}
serverModeDisplay="badge" embedInParentGrid
/> isNativeSsh={isNative}
<PrivateResourceSshFields />
control={asAnyControl(form.control)} </SettingsFormGrid>
setValue={asAnySetValue(form.setValue)} </SettingsSectionForm>
watch={asAnyWatch(form.watch)} </SettingsSectionBody>
orgId={siteResource.orgId}
selectedSites={selectedSites}
onSelectedSitesChange={setSelectedSites}
showSshSettings={false}
embedInParentGrid
showPaidFeaturesAlert={false}
isNativeSsh={isNative}
/>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>
<form action={formAction}> <form action={formAction}>
<Button type="submit" loading={saveLoading}> <Button type="submit" loading={saveLoading}>
{t("saveSettings")} {t("saveSettings")}
</Button> </Button>
</form> </form>
</SettingsSectionFooter> </SettingsSectionFooter>
</Form> </Form>
</fieldset>
</SettingsSection> </SettingsSection>
</SettingsContainer> </SettingsContainer>
); );
@@ -16,7 +16,6 @@ import {
type DescribedSelectOption type DescribedSelectOption
} from "@app/components/DescribedSelect"; } from "@app/components/DescribedSelect";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { import {
Form, Form,
@@ -30,7 +29,6 @@ import {
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import type { Selectedsite } from "@app/components/site-selector"; import type { Selectedsite } from "@app/components/site-selector";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { import {
@@ -77,12 +75,6 @@ export default function CreatePrivateResourcePage() {
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const orgId = params.orgId as string; 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 [isSubmitting, startTransition] = useTransition();
const siteIdParam = searchParams.get("siteId"); const siteIdParam = searchParams.get("siteId");
@@ -158,20 +150,16 @@ export default function CreatePrivateResourcePage() {
title: t("createInternalResourceDialogModeCidr"), title: t("createInternalResourceDialogModeCidr"),
description: t("privateResourceTypeCidrDescription") description: t("privateResourceTypeCidrDescription")
}, },
...(!disableEnterpriseFeatures {
? [ value: "http" as const,
{ title: t("createInternalResourceDialogModeHttp"),
value: "http" as const, description: t("privateResourceTypeHttpDescription")
title: t("createInternalResourceDialogModeHttp"), },
description: t("privateResourceTypeHttpDescription") {
}, value: "ssh" as const,
{ title: t("createInternalResourceDialogModeSsh"),
value: "ssh" as const, description: t("privateResourceTypeSshDescription")
title: t("createInternalResourceDialogModeSsh"), },
description: t("privateResourceTypeSshDescription")
}
]
: []),
{ {
value: "inference" as const, value: "inference" as const,
title: t("createInternalResourceDialogModeInference"), title: t("createInternalResourceDialogModeInference"),
@@ -179,11 +167,6 @@ export default function CreatePrivateResourcePage() {
} }
]; ];
const submitDisabled =
isSubmitting ||
(mode === "http" && httpSectionDisabled) ||
(mode === "ssh" && sshSectionDisabled);
function onSubmit(values: FormValues) { function onSubmit(values: FormValues) {
startTransition(async () => { startTransition(async () => {
try { try {
@@ -467,10 +450,6 @@ export default function CreatePrivateResourcePage() {
)} )}
watch={asAnyWatch(form.watch)} watch={asAnyWatch(form.watch)}
labelPrefix="create" labelPrefix="create"
disabled={
mode === "ssh" &&
sshSectionDisabled
}
/> />
</SettingsFormCell> </SettingsFormCell>
)} )}
@@ -584,9 +563,6 @@ export default function CreatePrivateResourcePage() {
{/* HTTP configuration */} {/* HTTP configuration */}
{mode === "http" && ( {mode === "http" && (
<SettingsSection> <SettingsSection>
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("httpSettings")} {t("httpSettings")}
@@ -597,62 +573,43 @@ export default function CreatePrivateResourcePage() {
)} )}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<fieldset
disabled={httpSectionDisabled} <SettingsSectionBody>
className={ <SettingsSectionForm variant="half">
httpSectionDisabled <SettingsFormGrid>
? "opacity-50 pointer-events-none" <SettingsFormCell span="half">
: "" <PrivateResourceSitesField
} control={form.control}
> orgId={orgId}
<SettingsSectionBody> selectedSites={selectedSites}
<SettingsSectionForm variant="half"> onSelectedSitesChange={
<SettingsFormGrid> setSelectedSites
<SettingsFormCell span="half"> }
<PrivateResourceSitesField />
control={form.control} </SettingsFormCell>
orgId={orgId} <SettingsFormCell span="full">
selectedSites={ <PrivateResourceHttpFields
selectedSites control={asAnyControl(
} form.control
onSelectedSitesChange={ )}
setSelectedSites setValue={asAnySetValue(
} form.setValue
/> )}
</SettingsFormCell> orgId={orgId}
<SettingsFormCell span="full"> watch={asAnyWatch(form.watch)}
<PrivateResourceHttpFields labelPrefix="create"
control={asAnyControl( hideDomainPicker
form.control />
)} </SettingsFormCell>
setValue={asAnySetValue( </SettingsFormGrid>
form.setValue </SettingsSectionForm>
)} </SettingsSectionBody>
orgId={orgId}
watch={asAnyWatch(
form.watch
)}
disabled={
httpSectionDisabled
}
labelPrefix="create"
hideDomainPicker
hidePaidFeaturesAlert
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection> </SettingsSection>
)} )}
{/* SSH server */} {/* SSH server */}
{mode === "ssh" && ( {mode === "ssh" && (
<SettingsSection> <SettingsSection>
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("sshSettings")} {t("sshSettings")}
@@ -661,37 +618,22 @@ export default function CreatePrivateResourcePage() {
{t("sshServerDescription")} {t("sshServerDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<fieldset <SettingsSectionBody>
disabled={sshSectionDisabled} <SettingsSectionForm variant="half">
className={ <PrivateResourceSshFields
sshSectionDisabled control={asAnyControl(form.control)}
? "opacity-50 pointer-events-none" setValue={asAnySetValue(form.setValue)}
: "" watch={asAnyWatch(form.watch)}
} orgId={orgId}
> selectedSites={selectedSites}
<SettingsSectionBody> onSelectedSitesChange={setSelectedSites}
<SettingsSectionForm variant="half"> labelPrefix="create"
<PrivateResourceSshFields showSshSettings={true}
control={asAnyControl(form.control)} layout="wizard"
setValue={asAnySetValue( hideAlias
form.setValue />
)} </SettingsSectionForm>
watch={asAnyWatch(form.watch)} </SettingsSectionBody>
orgId={orgId}
disabled={sshSectionDisabled}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
labelPrefix="create"
showSshSettings={true}
layout="wizard"
showPaidFeaturesAlert={false}
hideAlias
/>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection> </SettingsSection>
)} )}
@@ -776,7 +718,7 @@ export default function CreatePrivateResourcePage() {
<Button <Button
type="submit" type="submit"
form="create-private-resource-form" form="create-private-resource-form"
disabled={submitDisabled} disabled={isSubmitting}
loading={isSubmitting} loading={isSubmitting}
> >
{t("createInternalResourceDialogCreateResource")} {t("createInternalResourceDialogCreateResource")}
@@ -161,7 +161,7 @@ export default function ResourceMaintenancePage() {
return null; return null;
} }
const isMaintenanceDisabled = !isPaidUser(tierMatrix.maintencePage); const isMaintenanceDisabled = !isPaidUser(tierMatrix.maintenancePage);
const maintenanceModeTypeOptions: StrategyOption< const maintenanceModeTypeOptions: StrategyOption<
"automatic" | "forced" "automatic" | "forced"
@@ -180,7 +180,7 @@ export default function ResourceMaintenancePage() {
return ( return (
<> <>
<PaidFeaturesAlert tiers={tierMatrix.maintencePage} /> <PaidFeaturesAlert tiers={tierMatrix.maintenancePage} />
<div <div
className={ className={
isMaintenanceDisabled isMaintenanceDisabled
@@ -55,11 +55,7 @@ export default function RdpSettingsPage(props: {
}) { }) {
const params = use(props.params); const params = use(props.params);
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({ const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "rdp"], queryKey: ["resourceTargets", resource.resourceId, params.orgId, "rdp"],
@@ -75,14 +71,10 @@ export default function RdpSettingsPage(props: {
return ( return (
<SettingsContainer> <SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<RdpServerForm <RdpServerForm
orgId={params.orgId} orgId={params.orgId}
resource={resource} resource={resource}
updateResource={updateResource} updateResource={updateResource}
disabled={disabled}
targetsResponse={targetsResponse ?? { targets: [] }} targetsResponse={targetsResponse ?? { targets: [] }}
/> />
</SettingsContainer> </SettingsContainer>
@@ -92,13 +84,11 @@ export default function RdpSettingsPage(props: {
function RdpServerForm({ function RdpServerForm({
orgId, orgId,
resource, resource,
disabled,
targetsResponse targetsResponse
}: { }: {
orgId: string; orgId: string;
resource: GetResourceResponse; resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"]; updateResource: ResourceContextType["updateResource"];
disabled: boolean;
targetsResponse: ResourceTargetsResponse; targetsResponse: ResourceTargetsResponse;
}) { }) {
const t = useTranslations(); const t = useTranslations();
@@ -215,10 +205,6 @@ function RdpServerForm({
{t("rdpServerDescription")} {t("rdpServerDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<fieldset
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<Form {...form}> <Form {...form}>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
@@ -244,7 +230,6 @@ function RdpServerForm({
</Button> </Button>
</form> </form>
</Form> </Form>
</fieldset>
</SettingsSection> </SettingsSection>
); );
} }
@@ -75,11 +75,7 @@ export default function SshSettingsPage(props: {
}) { }) {
const params = use(props.params); const params = use(props.params);
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({ const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "ssh"], queryKey: ["resourceTargets", resource.resourceId, params.orgId, "ssh"],
@@ -95,14 +91,10 @@ export default function SshSettingsPage(props: {
return ( return (
<SettingsContainer> <SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<SshServerForm <SshServerForm
orgId={params.orgId} orgId={params.orgId}
resource={resource} resource={resource}
updateResource={updateResource} updateResource={updateResource}
disabled={disabled}
targetsResponse={targetsResponse ?? { targets: [] }} targetsResponse={targetsResponse ?? { targets: [] }}
/> />
</SettingsContainer> </SettingsContainer>
@@ -113,13 +105,11 @@ function SshServerForm({
orgId, orgId,
resource, resource,
updateResource, updateResource,
disabled,
targetsResponse targetsResponse
}: { }: {
orgId: string; orgId: string;
resource: GetResourceResponse; resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"]; updateResource: ResourceContextType["updateResource"];
disabled: boolean;
targetsResponse: ResourceTargetsResponse; targetsResponse: ResourceTargetsResponse;
}) { }) {
const t = useTranslations(); const t = useTranslations();
@@ -375,10 +365,6 @@ function SshServerForm({
{t("sshServerDescription")} {t("sshServerDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<fieldset
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<Form {...form}> <Form {...form}>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
@@ -530,7 +516,6 @@ function SshServerForm({
</Button> </Button>
</form> </form>
</Form> </Form>
</fieldset>
</SettingsSection> </SettingsSection>
); );
} }
@@ -55,11 +55,7 @@ export default function VncSettingsPage(props: {
}) { }) {
const params = use(props.params); const params = use(props.params);
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({ const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "vnc"], queryKey: ["resourceTargets", resource.resourceId, params.orgId, "vnc"],
@@ -75,14 +71,10 @@ export default function VncSettingsPage(props: {
return ( return (
<SettingsContainer> <SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<VncServerForm <VncServerForm
orgId={params.orgId} orgId={params.orgId}
resource={resource} resource={resource}
updateResource={updateResource} updateResource={updateResource}
disabled={disabled}
targetsResponse={targetsResponse ?? { targets: [] }} targetsResponse={targetsResponse ?? { targets: [] }}
/> />
</SettingsContainer> </SettingsContainer>
@@ -92,13 +84,11 @@ export default function VncSettingsPage(props: {
function VncServerForm({ function VncServerForm({
orgId, orgId,
resource, resource,
disabled,
targetsResponse targetsResponse
}: { }: {
orgId: string; orgId: string;
resource: GetResourceResponse; resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"]; updateResource: ResourceContextType["updateResource"];
disabled: boolean;
targetsResponse: ResourceTargetsResponse; targetsResponse: ResourceTargetsResponse;
}) { }) {
const t = useTranslations(); const t = useTranslations();
@@ -215,10 +205,6 @@ function VncServerForm({
{t("vncServerDescription")} {t("vncServerDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<fieldset
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<Form {...form}> <Form {...form}>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
@@ -244,7 +230,6 @@ function VncServerForm({
</Button> </Button>
</form> </form>
</Form> </Form>
</fieldset>
</SettingsSection> </SettingsSection>
); );
} }
@@ -239,14 +239,6 @@ export default function Page() {
// Resource type state // Resource type state
const [resourceType, setResourceType] = useState<NewResourceType>("http"); const [resourceType, setResourceType] = useState<NewResourceType>("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) // Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit)
const [targets, setTargets] = useState<LocalTarget[]>([]); const [targets, setTargets] = useState<LocalTarget[]>([]);
const [selectedProviders, setSelectedProviders] = useState< const [selectedProviders, setSelectedProviders] = useState<
@@ -1056,14 +1048,6 @@ export default function Page() {
{/* SSH Server Section */} {/* SSH Server Section */}
{resourceType === "ssh" && ( {resourceType === "ssh" && (
<SettingsSection> <SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("sshServer")} {t("sshServer")}
@@ -1072,14 +1056,7 @@ export default function Page() {
{t("sshServerDescription")} {t("sshServerDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<SettingsFormGrid> <SettingsFormGrid>
@@ -1318,21 +1295,12 @@ export default function Page() {
</SettingsFormGrid> </SettingsFormGrid>
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</fieldset>
</SettingsSection> </SettingsSection>
)} )}
{/* RDP Server Section */} {/* RDP Server Section */}
{resourceType === "rdp" && ( {resourceType === "rdp" && (
<SettingsSection> <SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("rdpServer")} {t("rdpServer")}
@@ -1341,14 +1309,6 @@ export default function Page() {
{t("rdpServerDescription")} {t("rdpServerDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<Form {...bgTargetForm}> <Form {...bgTargetForm}>
@@ -1365,21 +1325,12 @@ export default function Page() {
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</fieldset>
</SettingsSection> </SettingsSection>
)} )}
{/* VNC Server Section */} {/* VNC Server Section */}
{resourceType === "vnc" && ( {resourceType === "vnc" && (
<SettingsSection> <SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("vncServer")} {t("vncServer")}
@@ -1388,14 +1339,7 @@ export default function Page() {
{t("vncServerDescription")} {t("vncServerDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<Form {...bgTargetForm}> <Form {...bgTargetForm}>
@@ -1412,7 +1356,6 @@ export default function Page() {
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</fieldset>
</SettingsSection> </SettingsSection>
)} )}
@@ -1527,7 +1470,6 @@ export default function Page() {
loading={createLoading} loading={createLoading}
disabled={ disabled={
!areAllTargetsValid() || !areAllTargetsValid() ||
browserGatewayDisabled ||
createLoading createLoading
} }
> >
+1 -1
View File
@@ -399,7 +399,7 @@ function AuthPageSettings({
</div> </div>
)} )}
{build !== "oss" && (build === "enterprise" || {(build === "enterprise" ||
!isPaidUser( !isPaidUser(
tierMatrix.loginPageDomain tierMatrix.loginPageDomain
)) && )) &&
+1 -1
View File
@@ -52,7 +52,7 @@ export default function CreateRoleForm({
requireDeviceApproval: values.requireDeviceApproval, requireDeviceApproval: values.requireDeviceApproval,
allowSsh: values.allowSsh allowSsh: values.allowSsh
}; };
if (isPaidUser(tierMatrix.advancedPrivateResources)) { if (isPaidUser(tierMatrix.roleBasedSSHControls)) {
payload.sshSudoMode = values.sshSudoMode; payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir; payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands = payload.sshSudoCommands =
+2 -5
View File
@@ -59,7 +59,7 @@ export default function EditRoleForm({
payload.name = values.name; payload.name = values.name;
payload.description = values.description || undefined; payload.description = values.description || undefined;
} }
if (isPaidUser(tierMatrix.advancedPrivateResources)) { if (isPaidUser(tierMatrix.roleBasedSSHControls)) {
payload.sshSudoMode = values.sshSudoMode; payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir; payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands = payload.sshSudoCommands =
@@ -107,10 +107,7 @@ export default function EditRoleForm({
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("aiBudgetErrorSave"), title: t("aiBudgetErrorSave"),
description: formatAxiosError( description: formatAxiosError(e, t("aiBudgetErrorSave"))
e,
t("aiBudgetErrorSave")
)
}); });
} }
} }
+1 -12
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { import {
SettingsFormCell, SettingsFormCell,
SettingsFormGrid, SettingsFormGrid,
@@ -25,7 +24,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form"; import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
@@ -49,8 +47,7 @@ export function PrivateResourceHttpFields({
disabled = false, disabled = false,
siteResourceId, siteResourceId,
labelPrefix = "edit", labelPrefix = "edit",
hideDomainPicker = false, hideDomainPicker = false
hidePaidFeaturesAlert = false
}: PrivateResourceHttpFieldsProps) { }: PrivateResourceHttpFieldsProps) {
const t = useTranslations(); const t = useTranslations();
const schemeLabelKey = const schemeLabelKey =
@@ -88,14 +85,6 @@ export function PrivateResourceHttpFields({
return ( return (
<SettingsFormGrid> <SettingsFormGrid>
{!hidePaidFeaturesAlert && (
<SettingsFormCell span="full">
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="quarter"> <SettingsFormCell span="quarter">
<FormField <FormField
control={control} control={control}
+1 -3
View File
@@ -18,7 +18,6 @@ import {
type LauncherAccessFields type LauncherAccessFields
} from "@app/lib/launcherResourceAccess"; } from "@app/lib/launcherResourceAccess";
import type { PrivateResourceMode } from "@app/lib/privateResourceForm"; import type { PrivateResourceMode } from "@app/lib/privateResourceForm";
import { build } from "@server/build";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
type SiteResourceInfoInput = { type SiteResourceInfoInput = {
@@ -121,8 +120,7 @@ export function PrivateResourceInfoSections({
(siteResource.mode === "http" || siteResource.mode === "inference") && (siteResource.mode === "http" || siteResource.mode === "inference") &&
siteResource.ssl && siteResource.ssl &&
siteResource.domainId && siteResource.domainId &&
siteResource.fullDomain && siteResource.fullDomain
build != "oss"
); );
const showPortRestrictions = const showPortRestrictions =
isPanel && isPanel &&
@@ -38,7 +38,6 @@ type PrivateResourceSshFieldsProps = {
labelPrefix?: "create" | "edit"; labelPrefix?: "create" | "edit";
showSshSettings?: boolean; showSshSettings?: boolean;
layout?: "default" | "wizard"; layout?: "default" | "wizard";
showPaidFeaturesAlert?: boolean;
hideAlias?: boolean; hideAlias?: boolean;
embedInParentGrid?: boolean; embedInParentGrid?: boolean;
isNativeSsh?: boolean; isNativeSsh?: boolean;
@@ -55,7 +54,6 @@ export function PrivateResourceSshFields({
labelPrefix = "edit", labelPrefix = "edit",
showSshSettings = true, showSshSettings = true,
layout = "default", layout = "default",
showPaidFeaturesAlert = true,
hideAlias = false, hideAlias = false,
embedInParentGrid = false, embedInParentGrid = false,
isNativeSsh: isNativeSshProp isNativeSsh: isNativeSshProp
@@ -313,13 +311,6 @@ export function PrivateResourceSshFields({
const content: ReactNode = ( const content: ReactNode = (
<> <>
{showPaidFeaturesAlert && layout === "default" && (
<SettingsFormCell span="full">
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
</SettingsFormCell>
)}
{sshSettingsFields} {sshSettingsFields}
{destinationSection} {destinationSection}
</> </>
-1
View File
@@ -429,7 +429,6 @@ export default function PrivateResourcesTable({
const fullDomain = resourceRow.fullDomain; const fullDomain = resourceRow.fullDomain;
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`; const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
const did = const did =
build !== "oss" &&
resourceRow.ssl && resourceRow.ssl &&
domainId != null && domainId != null &&
domainId !== "" && domainId !== "" &&
-1
View File
@@ -468,7 +468,6 @@ export default function PublicResourcesTable({
const domainId = resourceRow.domainId; const domainId = resourceRow.domainId;
const certHostname = resourceRow.fullDomain; const certHostname = resourceRow.fullDomain;
const showHttpsCertIndicator = const showHttpsCertIndicator =
build !== "oss" &&
resourceRow.ssl && resourceRow.ssl &&
certHostname != null && certHostname != null &&
certHostname !== ""; certHostname !== "";
+1 -2
View File
@@ -40,8 +40,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const showCertificate = !!( const showCertificate = !!(
isDomainResource && isDomainResource &&
resource.domainId && resource.domainId &&
resource.fullDomain && resource.fullDomain
build != "oss"
); );
const showType = !!(isDomainResource && resource.mode); const showType = !!(isDomainResource && resource.mode);
const showAuth = resource.mode !== "inference"; const showAuth = resource.mode !== "inference";
+181 -183
View File
@@ -212,7 +212,7 @@ export function RoleForm({
} }
}, [variant, role, form]); }, [variant, role, form]);
const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); const sshDisabled = !isPaidUser(tierMatrix.roleBasedSSHControls);
const sshSudoMode = form.watch("sshSudoMode"); const sshSudoMode = form.watch("sshSudoMode");
const isAdminRole = variant === "edit" && role?.isAdmin === true; const isAdminRole = variant === "edit" && role?.isAdmin === true;
const [pendingImport, setPendingImport] = const [pendingImport, setPendingImport] =
@@ -235,12 +235,6 @@ export function RoleForm({
setAttemptedBudgetsSave(false); setAttemptedBudgetsSave(false);
}, [variant, budgetsQuery.data]); }, [variant, budgetsQuery.data]);
useEffect(() => {
if (sshDisabled) {
form.setValue("allowSsh", false);
}
}, [sshDisabled, form]);
async function handleFileDrop( async function handleFileDrop(
file: File, file: File,
field: RoleTextImportField field: RoleTextImportField
@@ -487,115 +481,157 @@ export function RoleForm({
/> />
</div> </div>
{/* SSH tab - hidden when enterprise features are disabled */} <div className="space-y-4 mt-4">
{!env.flags.disableEnterpriseFeatures && ( <FormField
<div className="space-y-4 mt-4"> control={form.control}
<PaidFeaturesAlert name="allowSsh"
tiers={tierMatrix.advancedPrivateResources} render={({ field }) => {
/> const allowSshOptions: OptionSelectOption<
<FormField "allow" | "disallow"
control={form.control} >[] = [
name="allowSsh" {
render={({ field }) => { value: "allow",
const allowSshOptions: OptionSelectOption< label: t("roleAllowSshAllow")
"allow" | "disallow" },
>[] = [ {
{ value: "disallow",
value: "allow", label: t("roleAllowSshDisallow")
label: t("roleAllowSshAllow") }
}, ];
{ return (
value: "disallow", <FormItem>
label: t("roleAllowSshDisallow") <FormLabel>
} {t("roleAllowSsh")}
]; </FormLabel>
return ( <OptionSelect<"allow" | "disallow">
<FormItem> options={allowSshOptions}
<FormLabel> value={
{t("roleAllowSsh")} field.value
</FormLabel> ? "allow"
<OptionSelect< : "disallow"
"allow" | "disallow"
>
options={allowSshOptions}
value={
sshDisabled
? "disallow"
: field.value
? "allow"
: "disallow"
}
onChange={(v) => {
if (sshDisabled) return;
field.onChange(
v === "allow"
);
}}
cols={2}
disabled={sshDisabled}
/>
<FormDescription>
{t(
"roleAllowSshDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="sshSudoMode"
render={({ field }) => {
const sudoOptions: OptionSelectOption<SshSudoMode>[] =
[
{
value: "none",
label: t("sshSudoModeNone")
},
{
value: "full",
label: t("sshSudoModeFull")
},
{
value: "commands",
label: t(
"sshSudoModeCommands"
)
} }
]; onChange={(v) => {
return ( field.onChange(
<FormItem> v === "allow"
<FormLabel> );
{t("sshSudoMode")} }}
</FormLabel> cols={2}
<OptionSelect<SshSudoMode> />
options={sudoOptions} <FormDescription>
value={field.value} {t("roleAllowSshDescription")}
onChange={field.onChange} </FormDescription>
cols={3} <FormMessage />
disabled={sshDisabled} </FormItem>
/> );
<FormMessage /> }}
</FormItem> />
); {/* SSH tab - hidden when enterprise features are disabled */}
}} {!env.flags.disableEnterpriseFeatures && (
/> <>
{sshSudoMode === "commands" && ( <PaidFeaturesAlert
tiers={tierMatrix.roleBasedSSHControls}
/>
<FormField <FormField
control={form.control} control={form.control}
name="sshSudoCommands" name="sshSudoMode"
render={({ field }) => {
const sudoOptions: OptionSelectOption<SshSudoMode>[] =
[
{
value: "none",
label: t(
"sshSudoModeNone"
)
},
{
value: "full",
label: t(
"sshSudoModeFull"
)
},
{
value: "commands",
label: t(
"sshSudoModeCommands"
)
}
];
return (
<FormItem>
<FormLabel>
{t("sshSudoMode")}
</FormLabel>
<OptionSelect<SshSudoMode>
options={sudoOptions}
value={field.value}
onChange={
field.onChange
}
cols={3}
disabled={sshDisabled}
/>
<FormMessage />
</FormItem>
);
}}
/>
{sshSudoMode === "commands" && (
<FormField
control={form.control}
name="sshSudoCommands"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshSudoCommands")}
</FormLabel>
<FormControl>
<Textarea
{...field}
{...getTextImportDropHandlers(
"sshSudoCommands"
)}
placeholder={
sshDisabled
? undefined
: t(
"roleTextFieldPlaceholder"
)
}
disabled={
sshDisabled
}
className={cn(
"h-20 min-h-20",
dragOverField ===
"sshSudoCommands" &&
"border-primary"
)}
/>
</FormControl>
<FormDescription>
{t(
"sshSudoCommandsDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="sshUnixGroups"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t("sshSudoCommands")} {t("sshUnixGroups")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
{...field} {...field}
{...getTextImportDropHandlers( {...getTextImportDropHandlers(
"sshSudoCommands" "sshUnixGroups"
)} )}
placeholder={ placeholder={
sshDisabled sshDisabled
@@ -608,97 +644,59 @@ export function RoleForm({
className={cn( className={cn(
"h-20 min-h-20", "h-20 min-h-20",
dragOverField === dragOverField ===
"sshSudoCommands" && "sshUnixGroups" &&
"border-primary" "border-primary"
)} )}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t( {t(
"sshSudoCommandsDescription" "sshUnixGroupsDescription"
)} )}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
)}
<FormField <FormField
control={form.control} control={form.control}
name="sshUnixGroups" name="sshCreateHomeDir"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="my-2">
<FormLabel> <FormControl>
{t("sshUnixGroups")} <CheckboxWithLabel
</FormLabel> {...field}
<FormControl> value="on"
<Textarea checked={form.watch(
{...field} "sshCreateHomeDir"
{...getTextImportDropHandlers( )}
"sshUnixGroups" onCheckedChange={(
)} checked
placeholder={ ) => {
sshDisabled if (
? undefined checked !==
: t( "indeterminate"
"roleTextFieldPlaceholder" ) {
) form.setValue(
} "sshCreateHomeDir",
disabled={sshDisabled} checked
className={cn( );
"h-20 min-h-20", }
dragOverField === }}
"sshUnixGroups" && label={t(
"border-primary" "sshCreateHomeDir"
)} )}
/> disabled={sshDisabled}
</FormControl> />
<FormDescription> </FormControl>
{t("sshUnixGroupsDescription")} <FormMessage />
</FormDescription> </FormItem>
<FormMessage /> )}
</FormItem> />
)} </>
/> )}
</div>
<FormField
control={form.control}
name="sshCreateHomeDir"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
value="on"
checked={form.watch(
"sshCreateHomeDir"
)}
onCheckedChange={(
checked
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"sshCreateHomeDir",
checked
);
}
}}
label={t(
"sshCreateHomeDir"
)}
disabled={sshDisabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
-7
View File
@@ -1,10 +1,3 @@
/**
* Set a cookie on the client side in javascript code, not on the server
* @param name
* @param value
* @param days
* @param options
*/
export function setClientCookie( export function setClientCookie(
name: string, name: string,
value: string, value: string,