Compare commits

..

6 Commits

Author SHA1 Message Date
Owen e7f5f04632 Pass 2 bring over browser resources and http resource cert gen 2026-08-14 18:06:46 -04:00
Owen 0c0606b158 Pass 1 of pulling traefik config into functions 2026-08-14 17:42:23 -04:00
Owen 52f5ad6523 Show the cert status on the frontend 2026-08-14 17:08:43 -04:00
Owen 780906a232 Move certificates 2026-08-14 16:57:18 -04:00
Owen b4c509e8f6 Move the acme cert sync 2026-08-14 16:44:18 -04:00
Owen 24998ce0a1 Remove advanced resources paywall 2026-08-14 16:33:05 -04:00
99 changed files with 3496 additions and 5388 deletions
-35
View File
@@ -1678,29 +1678,6 @@
"commandVirtualApiKeys": "Virtual API Keys",
"virtualApiKeysTitle": "Manage Virtual API Keys",
"virtualApiKeysDescription": "Create and manage manual API keys for AI Gateway access to public AI gateways",
"virtualApiKeysTabIdentity": "Identity Keys",
"virtualApiKeysTabVirtual": "Virtual Keys",
"virtualApiKeysIdentitySplashTitle": "Identity Keys for Every User",
"virtualApiKeysIdentitySplashDescription": "Every user already has a Pangolin identity key for this organization. It is unique to their account and authenticates them to AI gateways they can access.",
"virtualApiKeysIdentitySplashExample": "Example",
"virtualApiKeysIdentitySplashRetrieveTitle": "How Users Get Their Key",
"virtualApiKeysIdentitySplashRetrieveResource": "Visit a public AI gateway URL in the browser and log in with their account.",
"virtualApiKeysIdentitySplashRetrievePage": "Or go to <url></url> while signed in.",
"virtualApiKeysIdentitySplashManual": "You can create additional virtual API keys on the Virtual Keys tab. Those keys can be scoped to specific public AI gateways and optionally associated with a user. Creating a key immediately grants access to the selected public AI gateways.",
"virtualApiKeysIdentitySplashGoToVirtual": "Manually Create a Key",
"virtualApiKeysEmailIdentity": "Email Identity Keys",
"virtualApiKeysEmailIdentityDescription": "Send each selected user or role their Pangolin identity key.",
"virtualApiKeysEmailIdentitySendAll": "Send to all users",
"virtualApiKeysEmailIdentitySendAllDescription": "Email every organization member who has an account email.",
"virtualApiKeysEmailIdentitySelectUsers": "Users",
"virtualApiKeysEmailIdentitySelectRoles": "Roles",
"virtualApiKeysEmailIdentitySubmit": "Send Emails",
"virtualApiKeysEmailIdentitySuccess": "Identity keys emailed",
"virtualApiKeysEmailIdentitySuccessDescription": "Sent {sent} emails.",
"virtualApiKeysEmailIdentitySkipped": "{skipped} users were skipped because they do not have an email address.",
"virtualApiKeysEmailIdentityRecipientsRequired": "Select at least one user or role, or send to all users.",
"virtualApiKeysEmailIdentityError": "Error sending identity keys",
"virtualApiKeysEmailIdentityErrorDescription": "Failed to email identity keys",
"virtualApiKeysBannerTitle": "Identity Keys for Every User",
"virtualApiKeysBannerDescription": "Every user already has an identity key available at {keysUrl}. You can also manually generate keys here that grant direct access to public AI gateways.",
"virtualApiKeysBannerButtonText": "View Identity Keys",
@@ -1748,18 +1725,6 @@
"virtualApiKeysFilterUnassigned": "Unassigned",
"virtualApiKeysInferenceBudget": "Budget",
"virtualApiKeysInferenceBudgetDescription": "Configure how this key restricts AI usage based on spending or token limits",
"virtualApiKeysEmailOnGenerate": "Email key upon generation",
"virtualApiKeysEmailThisKey": "Email this key",
"virtualApiKeysEmailOnGenerateDescription": "Send the key to the associated user and additional addresses after it is created",
"virtualApiKeysEmailThisKeyDescription": "Send the current key to the associated user and additional addresses",
"virtualApiKeysEmailSmtpRequired": "Email is not configured on this server",
"virtualApiKeysEmailSmtpRequiredDescription": "Configure SMTP to email virtual API keys.",
"virtualApiKeysEmailSendToUser": "Send to associated user",
"virtualApiKeysEmailSendToUserDescription": "Email the key to the associated user's account email",
"virtualApiKeysEmailSendToUserDisabled": "Associate a user to send the key to that user",
"virtualApiKeysEmailAdditional": "Additional emails",
"virtualApiKeysEmailAdditionalPlaceholder": "Add email and press Enter",
"virtualApiKeysEmailRecipientsRequired": "Select the associated user or add at least one email address",
"myVirtualApiKeysTitle": "Your API Keys",
"myVirtualApiKeysDescription": "View your identity key and any virtual API keys attributed to you in this organization",
"myVirtualApiKeysResourceTitle": "Your API Keys for {resourceName}",
+2 -2
View File
@@ -166,7 +166,7 @@
"@types/yargs": "17.0.35",
"babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.10",
"esbuild": "0.28.0",
"esbuild": "0.28.1",
"esbuild-node-externals": "1.22.0",
"eslint": "10.4.0",
"eslint-config-next": "16.2.6",
@@ -180,7 +180,7 @@
"typescript-eslint": "8.60.0"
},
"overrides": {
"esbuild": "0.28.0",
"esbuild": "0.28.1",
"dompurify": "3.4.0",
"postcss": "8.5.15"
}
-20
View File
@@ -29,25 +29,6 @@ import {
labels
} from "./schema";
export const certificates = pgTable("certificates", {
certId: serial("certId").primaryKey(),
domain: varchar("domain", { length: 255 }).notNull().unique(),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "cascade"
}),
wildcard: boolean("wildcard").default(false),
status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, requested, valid, expired, failed
expiresAt: bigint("expiresAt", { mode: "number" }),
lastRenewalAttempt: bigint("lastRenewalAttempt", { mode: "number" }),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull(),
orderId: varchar("orderId", { length: 500 }),
errorMessage: text("errorMessage"),
renewalCount: integer("renewalCount").default(0),
certFile: text("certFile"),
keyFile: text("keyFile")
});
export const dnsChallenge = pgTable("dnsChallenges", {
dnsChallengeId: serial("dnsChallengeId").primaryKey(),
domain: varchar("domain", { length: 255 }).notNull(),
@@ -633,7 +614,6 @@ export const trialNotifications = pgTable("trialNotifications", {
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
export type Certificate = InferSelectModel<typeof certificates>;
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
export type Customer = InferSelectModel<typeof customers>;
export type Subscription = InferSelectModel<typeof subscriptions>;
+20
View File
@@ -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 User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -2117,3 +2136,4 @@ export type SiteResourceAiProvider = InferSelectModel<
>;
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
export type Certificate = InferSelectModel<typeof certificates>;
-20
View File
@@ -23,25 +23,6 @@ import {
users
} from "./schema";
export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
domain: text("domain").notNull().unique(),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "cascade"
}),
wildcard: integer("wildcard", { mode: "boolean" }).default(false),
status: text("status").notNull().default("pending"), // pending, requested, valid, expired, failed
expiresAt: integer("expiresAt"),
lastRenewalAttempt: integer("lastRenewalAttempt"),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull(),
orderId: text("orderId"),
errorMessage: text("errorMessage"),
renewalCount: integer("renewalCount").default(0),
certFile: text("certFile"),
keyFile: text("keyFile")
});
export const dnsChallenge = sqliteTable("dnsChallenges", {
dnsChallengeId: integer("dnsChallengeId").primaryKey({
autoIncrement: true
@@ -628,7 +609,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
export type Certificate = InferSelectModel<typeof certificates>;
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
export type Customer = InferSelectModel<typeof customers>;
export type Subscription = InferSelectModel<typeof subscriptions>;
+20
View File
@@ -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 User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -2111,3 +2130,4 @@ export type SiteResourceAiProvider = InferSelectModel<
>;
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
export type Certificate = InferSelectModel<typeof certificates>;
@@ -1,78 +0,0 @@
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailHeading,
EmailInfoSection,
EmailLetterHead,
EmailSection,
EmailSignature,
EmailText
} from "./components/Email";
type IdentityApiKeyGeneratedProps = {
orgName: string;
accountLabel?: string | null;
credential: string;
resourceUrls: string[];
hasMoreResources: boolean;
};
export const IdentityApiKeyGenerated = ({
orgName,
accountLabel,
credential,
resourceUrls,
hasMoreResources
}: IdentityApiKeyGeneratedProps) => {
const previewText = `Your personal identity key for ${orgName}`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
This is your personal identity key for{" "}
<strong>{orgName}</strong>. It belongs to your
account and identifies you when you use public AI
gateways.
</EmailText>
<EmailText>
Use it with resources your administrator has granted
you, or that your role has access to. Treat this key
like a password and do not share it.
</EmailText>
<EmailSection>
<EmailText>Your identity key:</EmailText>
<div className="inline-block max-w-full">
<div className="bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 mx-auto text-left">
<span className="text-sm font-mono text-gray-900 break-all">
{credential}
</span>
</div>
</div>
</EmailSection>
<EmailFooter>
<EmailSignature />
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>
);
};
export default IdentityApiKeyGenerated;
@@ -1,119 +0,0 @@
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailInfoSection,
EmailLetterHead,
EmailSection,
EmailSignature,
EmailText
} from "./components/Email";
type VirtualApiKeyGeneratedProps = {
orgName: string;
keyName: string | null;
credential: string;
resourceUrls: string[];
hasMoreResources: boolean;
};
export const VirtualApiKeyGenerated = ({
orgName,
keyName,
credential,
resourceUrls,
hasMoreResources
}: VirtualApiKeyGeneratedProps) => {
const previewText = `A virtual API key for ${orgName} has been shared with you`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
A virtual API key for <strong>{orgName}</strong> has
been shared with you. This key grants access to the
public AI gateways it was created for. Treat this
key like a password and do not share it.
</EmailText>
<EmailSection>
<EmailText>Your virtual API key:</EmailText>
<div className="inline-block max-w-full">
<div className="bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 mx-auto text-left">
<span className="text-sm font-mono text-gray-900 break-all">
{credential}
</span>
</div>
</div>
</EmailSection>
<EmailInfoSection
title="Key details"
items={[
{
label: "Organization",
value: orgName
},
...(keyName
? [
{
label: "Name",
value: keyName
}
]
: [])
]}
/>
{resourceUrls.length > 0 && (
<>
<EmailText>
This key can be used to authenticate to the
following AI gateway resources:
</EmailText>
<div className="px-6 pb-2">
{resourceUrls.map((url) => (
<p
key={url}
className="text-base text-gray-700 leading-relaxed"
>
<a
href={url}
className="text-primary font-medium break-all"
>
{url}
</a>
</p>
))}
</div>
{hasMoreResources && (
<EmailText>
Contact your administrator to get the
full list.
</EmailText>
)}
</>
)}
<EmailFooter>
<EmailSignature />
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>
);
};
export default VirtualApiKeyGenerated;
+1 -1
View File
@@ -18,7 +18,7 @@ export function EmailLetterHead() {
<Img
src="https://fossorial-public-assets.s3.us-east-1.amazonaws.com/word_mark_black.png"
alt="Pangolin Logo"
width="135"
width="180"
height="auto"
className="mx-auto"
/>
+1 -1
View File
@@ -27,7 +27,7 @@ import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
import { initCleanup } from "#dynamic/cleanup";
import license from "#dynamic/license/license";
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
import { initAcmeCertSync } from "@server/lib/acmeCertSync";
import { fetchServerIp } from "@server/lib/serverIpService";
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
+865 -2
View File
@@ -1,3 +1,866 @@
import fs from "fs";
import path from "path";
import crypto from "crypto";
import {
certificates,
clients,
clientSiteResourcesAssociationsCache,
db,
domains,
newts,
siteNetworks,
SiteResource,
siteResources
} from "@server/db";
import { and, eq } from "drizzle-orm";
import { encrypt, decrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import config from "@server/lib/config";
import {
generateSubnetProxyTargetV2,
SubnetProxyTargetV2
} from "@server/lib/ip";
import { updateTargets } from "@server/routers/client/targets";
import cache from "#dynamic/lib/cache";
import { build } from "@server/build";
interface AcmeCert {
domain: { main: string; sans?: string[] };
certificate: string;
key: string;
Store: string;
}
interface AcmeJson {
[resolver: string]: {
Certificates: AcmeCert[];
};
}
export async function pushCertUpdateToAffectedNewts(
domain: string,
domainId: string | null,
oldCertPem: string | null,
oldKeyPem: string | null
): Promise<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 {
// stub
}
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
ConnectionLogs = "connectionLogs",
RotateCredentials = "rotateCredentials",
MaintencePage = "maintencePage", // handle downgrade
MaintenancePage = "maintenancePage", // handle downgrade
DevicePosture = "devicePosture",
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
@@ -25,8 +25,7 @@ export enum TierFeature {
WildcardSubdomain = "wildcardSubdomain",
NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies",
AdvancedPublicResources = "advancedPublicResources",
AdvancedPrivateResources = "advancedPrivateResources"
RoleBasedSSHControls = "roleBasedSSHControls"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -39,7 +38,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.MaintenancePage]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
[TierFeature.TwoFactorEnforcement]: [
"tier1",
@@ -69,6 +68,5 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
[TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"],
[TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"]
[TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"]
};
+1 -25
View File
@@ -23,7 +23,7 @@ import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels";
import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { createCertificate } from "@server/routers/certificates/createCertificate";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { build } from "@server/build";
@@ -128,30 +128,6 @@ export async function updatePrivateResources(
for (const [resourceNiceId, resourceData] of Object.entries(
config["client-resources"]
)) {
if (resourceData.mode === "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasHttpFeature) {
throw new Error(
"HTTP private resources are not included in your current plan. Please upgrade."
);
}
}
if (resourceData.mode === "ssh") {
const hasSshFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasSshFeature) {
throw new Error(
"SSH private resources are not included in your current plan. Please upgrade."
);
}
}
const [existingResource] = await trx
.select()
.from(siteResources)
+3 -15
View File
@@ -1,5 +1,5 @@
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { createCertificate } from "@server/routers/certificates/createCertificate";
import { hashPassword } from "@server/auth/password";
import { generateId } from "@server/auth/sessions/app";
import { build } from "@server/build";
@@ -262,18 +262,6 @@ export async function updatePublicResources(
headers = JSON.stringify(resourceData.headers);
}
if (["ssh", "rdp", "vnc"].includes(resourceData.mode || "")) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPublicResources
);
if (!isLicensed) {
throw new Error(
"Your current subscription does not support browser gateway resources. Please upgrade to access this feature."
);
}
}
if (resourceData.policy) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
@@ -331,7 +319,7 @@ export async function updatePublicResources(
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.maintencePage
tierMatrix.maintenancePage
);
if (!isLicensed) {
resourceData.maintenance = undefined;
@@ -1138,7 +1126,7 @@ export async function updatePublicResources(
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.maintencePage
tierMatrix.maintenancePage
);
if (!isLicensed) {
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(
domains: Set<string>,
useCache: boolean = true
): Promise<
Array<{
id: number;
domain: string;
queriedDomain: string;
wildcard: boolean | null;
certFile: string | null;
keyFile: string | null;
expiresAt: number | null;
updatedAt?: number | null;
}>
> {
return []; // stub
): Promise<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;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import z from "zod";
import logger from "@server/logger";
import semver from "semver";
import { createHash } from "crypto";
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
import { getValidCertificatesForDomains } from "@server/lib/certificates";
import { lockManager } from "#dynamic/lib/lock";
interface IPRange {
+14 -4
View File
@@ -167,9 +167,8 @@ export const configSchema = z
.transform((val) =>
process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER !==
undefined
? process.env
.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER ===
"true"
? process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER ===
"true"
: val
),
secret: z.string().pipe(z.string().min(8)).optional(),
@@ -443,7 +442,18 @@ export const configSchema = z
disable_basic_wireguard_sites: z.boolean().optional(),
disable_config_managed_domains: z.boolean().optional(),
disable_product_help_banners: z.boolean().optional(),
disable_enterprise_features: z.boolean().optional()
disable_enterprise_features: z.boolean().optional(),
enable_acme_cert_sync: z.boolean().optional().default(true)
})
.optional(),
acme: z
.object({
acme_json_path: z
.string()
.optional()
.default("config/letsencrypt/acme.json"),
acme_http_endpoint: z.string().optional(),
sync_interval_ms: z.number().optional().default(5000)
})
.optional(),
ai: z
-188
View File
@@ -1,188 +0,0 @@
import { db, resources, users, virtualApiKeyResources } from "@server/db";
import { and, asc, eq } from "drizzle-orm";
import config from "@server/lib/config";
import { sendEmail } from "@server/emails";
import IdentityApiKeyGenerated from "@server/emails/templates/IdentityApiKeyGenerated";
import VirtualApiKeyGenerated from "@server/emails/templates/VirtualApiKeyGenerated";
import { formatVirtualApiKeyCredential } from "@server/lib/virtualApiKey";
const EMAIL_GATEWAY_URL_LIMIT = 5;
async function listVirtualApiKeyGatewayUrls(params: {
orgId: string;
allResources: boolean;
virtualApiKeyId: string;
}): Promise<{ urls: string[]; hasMore: boolean }> {
const rows = params.allResources
? await db
.select({
fullDomain: resources.fullDomain,
ssl: resources.ssl
})
.from(resources)
.where(
and(
eq(resources.orgId, params.orgId),
eq(resources.mode, "inference")
)
)
.orderBy(asc(resources.name))
.limit(EMAIL_GATEWAY_URL_LIMIT + 1)
: await db
.select({
fullDomain: resources.fullDomain,
ssl: resources.ssl
})
.from(virtualApiKeyResources)
.innerJoin(
resources,
eq(virtualApiKeyResources.resourceId, resources.resourceId)
)
.where(
eq(
virtualApiKeyResources.virtualApiKeyId,
params.virtualApiKeyId
)
)
.orderBy(asc(resources.name))
.limit(EMAIL_GATEWAY_URL_LIMIT + 1);
const urls = rows
.map((row) =>
row.fullDomain
? `${row.ssl ? "https" : "http"}://${row.fullDomain}`
: null
)
.filter((url): url is string => Boolean(url));
return {
urls: urls.slice(0, EMAIL_GATEWAY_URL_LIMIT),
hasMore: rows.length > EMAIL_GATEWAY_URL_LIMIT
};
}
export async function listOrgInferenceGatewayUrls(orgId: string): Promise<{
urls: string[];
hasMore: boolean;
}> {
return listVirtualApiKeyGatewayUrls({
orgId,
allResources: true,
virtualApiKeyId: ""
});
}
export async function resolveVirtualApiKeyEmailRecipients(params: {
sendEmail: boolean;
sendToAttributedUser: boolean;
userId: string | null | undefined;
emails: string[];
}): Promise<
{ ok: true; recipients: string[] } | { ok: false; message: string }
> {
if (!params.sendEmail) {
return { ok: true, recipients: [] };
}
if (!config.getRawConfig().email) {
return {
ok: false,
message: "Email is not configured on this server"
};
}
const recipients = new Set(
params.emails.map((email) => email.trim().toLowerCase()).filter(Boolean)
);
if (params.sendToAttributedUser) {
if (!params.userId) {
return {
ok: false,
message: "Associate a user to email the key to that user"
};
}
const [user] = await db
.select({ email: users.email })
.from(users)
.where(eq(users.userId, params.userId))
.limit(1);
if (!user?.email) {
return {
ok: false,
message: "The associated user does not have an email address"
};
}
recipients.add(user.email.toLowerCase());
}
if (recipients.size === 0) {
return {
ok: false,
message: "Select at least one email recipient"
};
}
return { ok: true, recipients: [...recipients] };
}
export async function sendVirtualApiKeyEmails(params: {
recipients: string[];
orgName: string;
orgId: string;
keyName: string | null;
virtualApiKeyId: string;
secret: string;
allResources: boolean;
isIdentityKey?: boolean;
accountLabel?: string | null;
gatewayUrls?: { urls: string[]; hasMore: boolean };
}): Promise<void> {
if (params.recipients.length === 0) {
return;
}
const credential = formatVirtualApiKeyCredential(
params.virtualApiKeyId,
params.secret
);
const { urls, hasMore } =
params.gatewayUrls ??
(await listVirtualApiKeyGatewayUrls({
orgId: params.orgId,
allResources: params.allResources,
virtualApiKeyId: params.virtualApiKeyId
}));
const from = config.getNoReplyEmail();
const subject = params.isIdentityKey
? `Your identity key for ${params.orgName}`
: `Virtual API key for ${params.orgName}`;
for (const to of params.recipients) {
await sendEmail(
params.isIdentityKey
? IdentityApiKeyGenerated({
orgName: params.orgName,
accountLabel: params.accountLabel,
credential,
resourceUrls: urls,
hasMoreResources: hasMore
})
: VirtualApiKeyGenerated({
orgName: params.orgName,
keyName: params.keyName,
credential,
resourceUrls: urls,
hasMoreResources: hasMore
}),
{
to,
from,
subject
}
);
}
}
+2 -3
View File
@@ -8,7 +8,7 @@ import { db, exitNodes } from "@server/db";
import { eq } from "drizzle-orm";
import { getCurrentExitNodeId } from "@server/lib/exitNodes";
import { getTraefikConfig } from "#dynamic/lib/traefik";
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
import { getValidCertificatesForDomains } from "@server/lib/certificates";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
@@ -628,8 +628,7 @@ export class TraefikConfigManager {
.name,
remoteRoleHeader:
config.getRawConfig().server.remote_headers
.role
config.getRawConfig().server.remote_headers.role
}
}
};
+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,
resourceAiProviders,
siteResources,
siteNetworks,
exitNodes
} from "@server/db";
import {
@@ -20,47 +21,47 @@ import {
} from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
import { resources, sites, Target, targets } from "@server/db";
import createPathRewriteMiddleware from "./middleware";
import { resources, sites, targets } from "@server/db";
import { applyPathRewriteMiddleware } from "./middleware";
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
import regionalCache from "@server/lib/cache";
import { TargetWithSite } from "./types";
import { buildWildcardTls } from "./certResolver";
import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule";
import {
AI_GATEWAY_TRUST_HEADER,
AI_GATEWAY_RESOURCE_TYPE_HEADER,
AI_GATEWAY_CLIENT_IP_HEADER,
getAiGatewayTrustToken
} from "@server/lib/aiGatewayTrust";
buildHttpLoadBalancerServers,
buildStickySessionCookie,
buildTcpUdpLoadBalancerServers,
buildStickySessionIp
} from "./loadBalancer";
import { buildCustomHeadersMiddleware } from "./headersMiddleware";
import {
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME,
getAiGatewayHost,
buildAiGatewayTrustMiddlewares,
buildAiGatewayClientIpMiddleware,
buildAiGatewayHostHeaderMiddleware,
buildAiGatewayRouterAndService
} from "./aiGatewayMiddlewares";
import {
buildBrowserGatewayResourcesMap,
buildBrowserGatewayConfig
} from "./browserGateway";
import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias";
const redirectHttpsMiddlewareName = "redirect-to-https";
const badgerMiddlewareName = "badger";
// Define extended target type with site information
type TargetWithSite = Target & {
resourceId: number;
targetId: number;
ip: string | null;
method: string | null;
port: number | null;
internalPort: number | null;
enabled: boolean;
health: string | null;
site: {
siteId: number;
type: string;
subnet: string | null;
exitNodeId: number | null;
online: boolean;
};
};
export async function getTraefikConfig(
exitNodeId: number,
siteTypes: string[],
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
allowRawResources = true,
maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
browserGatewayUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
maintenancePageUiUrl: string | null = null,
browserGatewayUiUrl: string | null = null,
aiGatewayUrl: string | null = null
): Promise<any> {
// Get the exit node but cache it for 5 minutes to avoid hitting the DB too often
@@ -98,8 +99,15 @@ export async function getTraefikConfig(
headers: resources.headers,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
wildcard: resources.wildcard,
mode: resources.mode,
maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType,
maintenanceTitle: resources.maintenanceTitle,
maintenanceMessage: resources.maintenanceMessage,
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
// Target fields
targetId: targets.targetId,
targetEnabled: targets.enabled,
@@ -146,8 +154,15 @@ export async function getTraefikConfig(
),
inArray(sites.type, siteTypes),
allowRawResources
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
: eq(resources.mode, "http")
? inArray(resources.mode, [
"http",
"udp",
"tcp",
"vnc",
"ssh",
"rdp"
]) // allow all three, plus browser-gateway modes
: inArray(resources.mode, ["http", "vnc", "ssh", "rdp"])
)
)
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
@@ -156,6 +171,9 @@ export async function getTraefikConfig(
const resourcesMap = new Map();
resourcesWithTargetsAndSites.forEach((row) => {
if (!["http", "tcp", "udp"].includes(row.mode)) {
return;
}
const resourceId = row.resourceId;
const resourceName = sanitize(row.resourceName) || "";
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
@@ -240,6 +258,40 @@ export async function getTraefikConfig(
});
});
// Group browser gateway targets by resource (SSH/VNC/RDP-mode resources
// served through the browser gateway web UI instead of a real target).
const browserGatewayResourcesMap = browserGatewayUiUrl
? buildBrowserGatewayResourcesMap(
resourcesWithTargetsAndSites,
filterOutNamespaceDomains
)
: new Map();
// Query siteResources in HTTP mode with SSL enabled and aliases, so
// Traefik generates TLS certificates for those domains even before a
// matching resource exists.
const siteResourcesWithFullDomain = await db
.select({
siteResourceId: siteResources.siteResourceId,
fullDomain: siteResources.fullDomain
})
.from(siteResources)
.innerJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
eq(siteResources.enabled, true),
isNotNull(siteResources.fullDomain),
eq(siteResources.mode, "http"), // important so we dont double get the inference siteResources below
eq(siteResources.ssl, true),
eq(sites.exitNodeId, exitNodeId),
inArray(sites.type, siteTypes)
)
);
// Inference-mode resources have no targets/sites (their "backend" is the
// central AI gateway), so they can't be reached via the targets->sites
// join above - query them separately and include them on every exit node.
@@ -275,7 +327,12 @@ export async function getTraefikConfig(
);
// make sure we have at least one resource
if (resourcesMap.size === 0 && inferenceResources.length === 0) {
if (
resourcesMap.size === 0 &&
inferenceResources.length === 0 &&
browserGatewayResourcesMap.size === 0 &&
siteResourcesWithFullDomain.length === 0
) {
return {};
}
@@ -319,56 +376,12 @@ export async function getTraefikConfig(
config_output.http.services = {};
}
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!resource.subdomain) {
wildCard = resource.fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const domainCertResolver = resource.domainCertResolver;
const preferWildcardCert = resource.preferWildcardCert;
let resolverName: string | undefined;
let preferWildcard: boolean | undefined;
// Handle both letsencrypt & custom cases
if (domainCertResolver) {
resolverName = domainCertResolver.trim();
} else {
resolverName = globalDefaultResolver;
}
if (
preferWildcardCert !== undefined &&
preferWildcardCert !== null
) {
preferWildcard = preferWildcardCert;
} else {
preferWildcard = globalDefaultPreferWildcard;
}
const tls = {
certResolver: resolverName,
...(preferWildcard
? {
domains: [
{
main: wildCard
}
]
}
: {})
};
const tls = buildWildcardTls({
fullDomain,
hasSubdomain: !!resource.subdomain,
domainCertResolver: resource.domainCertResolver,
preferWildcardCert: resource.preferWildcardCert
});
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
@@ -379,134 +392,40 @@ export async function getTraefikConfig(
];
// Handle path rewriting middleware
if (
resource.rewritePath !== null &&
resource.path !== null &&
resource.pathMatchType &&
resource.rewritePathType
) {
// Create a unique middleware name
const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${key}`;
try {
const rewriteResult = createPathRewriteMiddleware(
rewriteMiddlewareName,
resource.path,
resource.pathMatchType,
resource.rewritePath,
resource.rewritePathType
);
// Initialize middlewares object if it doesn't exist
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
// the middleware to the config
Object.assign(
config_output.http.middlewares,
rewriteResult.middlewares
);
// middlewares to the router middleware chain
if (rewriteResult.chain) {
// For chained middlewares (like stripPrefix + addPrefix)
routerMiddlewares.push(...rewriteResult.chain);
} else {
// Single middleware
routerMiddlewares.push(rewriteMiddlewareName);
}
// logger.debug(
// `Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})`
// );
} catch (error) {
logger.error(
`Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}`
);
}
}
applyPathRewriteMiddleware(
config_output,
resource.resourceId,
key,
resource.path,
resource.pathMatchType,
resource.rewritePath,
resource.rewritePathType,
routerMiddlewares
);
// Handle custom headers middleware
if (resource.headers || resource.setHostHeader) {
const headersObj: { [key: string]: string } = {};
if (resource.headers) {
let headersArr: { name: string; value: string }[] = [];
try {
headersArr = JSON.parse(resource.headers) as {
name: string;
value: string;
}[];
} catch (e) {
logger.warn(
`Failed to parse headers for resource ${resource.resourceId}: ${e}`
);
}
headersArr.forEach((header) => {
headersObj[header.name] = header.value;
});
}
if (resource.setHostHeader) {
headersObj["Host"] = resource.setHostHeader;
}
if (Object.keys(headersObj).length > 0) {
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[headersMiddlewareName] = {
headers: {
customRequestHeaders: headersObj
}
};
routerMiddlewares.push(headersMiddlewareName);
const customHeadersMiddleware = buildCustomHeadersMiddleware(
resource.headers,
resource.setHostHeader,
resource.resourceId
);
if (customHeadersMiddleware) {
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[headersMiddlewareName] =
customHeadersMiddleware;
routerMiddlewares.push(headersMiddlewareName);
}
// Build routing rules
let rule = `Host(\`${fullDomain}\`)`;
// priority logic
let priority: number;
if (resource.priority && resource.priority != 100) {
priority = resource.priority;
} else {
priority = 100;
if (resource.path && resource.pathMatchType) {
priority += 10;
if (resource.pathMatchType === "exact") {
priority += 5;
} else if (resource.pathMatchType === "prefix") {
priority += 3;
} else if (resource.pathMatchType === "regex") {
priority += 2;
}
if (resource.path === "/") {
priority = 1; // lowest for catch-all
}
}
}
if (resource.path && resource.pathMatchType) {
// priority += 1;
// add path to rule based on match type
let path = resource.path;
// if the path doesn't start with a /, add it
if (!path.startsWith("/")) {
path = `/${path}`;
}
if (resource.pathMatchType === "exact") {
rule += ` && Path(\`${path}\`)`;
} else if (resource.pathMatchType === "prefix") {
rule += ` && PathPrefix(\`${path}\`)`;
} else if (resource.pathMatchType === "regex") {
rule += ` && PathRegexp(\`${resource.path}\`)`; // this is the raw path because it's a regex
}
}
let rule = buildHostRule(fullDomain);
const priority = computeRoutePriority(
resource.priority,
resource.path,
resource.pathMatchType
);
rule = appendPathMatch(rule, resource.path, resource.pathMatchType);
config_output.http.routers![routerName] = {
entryPoints: [
@@ -535,90 +454,9 @@ export async function getTraefikConfig(
config_output.http.services![serviceName] = {
loadBalancer: {
servers: (() => {
// Check if any sites are online
// THIS IS SO THAT THERE IS SOME IMMEDIATE FEEDBACK
// EVEN IF THE SITES HAVE NOT UPDATED YET FROM THE
// RECEIVE BANDWIDTH ENDPOINT.
// TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some(
(target) => target.site.online
);
return (
targets
.filter((target) => {
if (!target.enabled) {
return false;
}
if (target.health == "unhealthy") {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
}
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
if (
!target.ip ||
!target.port ||
!target.method
) {
return false;
}
} else if (target.site.type === "newt") {
if (
!target.internalPort ||
!target.method ||
!target.site.subnet
) {
return false;
}
}
return true;
})
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
url: `${target.method}://${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
const ip =
target.site.subnet!.split("/")[0];
return {
url: `${target.method}://${ip}:${target.internalPort}`
};
}
})
// filter out duplicates
.filter(
(v, i, a) =>
a.findIndex(
(t) => t && v && t.url === v.url
) === i
)
);
})(),
servers: buildHttpLoadBalancerServers(targets),
...(resource.stickySession
? {
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
? buildStickySessionCookie(resource.ssl)
: {})
}
};
@@ -668,77 +506,67 @@ export async function getTraefikConfig(
config_output[protocol].services[serviceName] = {
loadBalancer: {
servers: (() => {
// Check if any sites are online
const anySitesOnline = targets.some(
(target) => target.site.online
);
return targets
.filter((target) => {
if (!target.enabled) {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
}
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
if (!target.ip || !target.port) {
return false;
}
} else if (target.site.type === "newt") {
if (
!target.internalPort ||
!target.site.subnet
) {
return false;
}
}
return true;
})
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
address: `${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
const ip =
target.site.subnet!.split("/")[0];
return {
address: `${ip}:${target.internalPort}`
};
}
});
})(),
servers: buildTcpUdpLoadBalancerServers(targets),
...(resource.proxyProtocol && protocol == "tcp"
? {
serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues?
}
: {}),
...(resource.stickySession
? {
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
: {})
...(resource.stickySession ? buildStickySessionIp() : {})
}
};
}
}
if (browserGatewayUiUrl) {
buildBrowserGatewayConfig({
config_output,
browserGatewayResourcesMap,
browserGatewayUiUrl,
maintenancePageUiUrl,
badgerMiddlewareName,
redirectHttpsMiddlewareName,
resolveTls: ({
fullDomain,
hasSubdomain,
domainCertResolver,
preferWildcardCert
}) =>
buildWildcardTls({
fullDomain,
hasSubdomain,
domainCertResolver,
preferWildcardCert
})
});
}
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
// Traefik generates TLS certificates for those domains even when no
// matching resource exists yet.
if (siteResourcesWithFullDomain.length > 0) {
// Build a set of domains already covered by normal resources
const existingFullDomains = new Set<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) {
// The AI gateway may live on a different host than the inference
// resource itself (e.g. a remote exit node forwarding to the
@@ -747,64 +575,23 @@ export async function getTraefikConfig(
// recognize, so we pin the Host header to the gateway's own host
// and smuggle the original resource host through in "p-host"
// instead.
let aiGatewayHost: string | undefined;
try {
aiGatewayHost = new URL(aiGatewayUrl).host;
} catch {
aiGatewayHost = undefined;
}
const aiGatewayHost = getAiGatewayHost(aiGatewayUrl);
// The trust token is the same for every inference route on this exit
// node, so it's defined once here and attached to each router below
// instead of being duplicated into a per-resource middleware. Two
// variants exist (public resource vs. siteResource) so the resource
// type header lets the gateway know which kind of router the
// request came through without re-deriving it from resourceId.
const aiGatewayTrustMiddlewareNameResource =
"ai-gateway-trust-headers-resource";
const aiGatewayTrustMiddlewareNameSiteResource =
"ai-gateway-trust-headers-site-resource";
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[aiGatewayTrustMiddlewareNameResource] = {
headers: {
customRequestHeaders: {
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(),
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource"
}
}
};
config_output.http.middlewares[
aiGatewayTrustMiddlewareNameSiteResource
] = {
headers: {
customRequestHeaders: {
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(),
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "site-resource"
}
}
};
Object.assign(
config_output.http.middlewares,
buildAiGatewayTrustMiddlewares()
);
// Opt-in: a Badger instance with forward auth disabled, used only
// to stamp the resolved client IP into a dedicated header before
// the request reaches whatever sits between Traefik and the AI
// gateway. Only the site-resource router below needs this - it's
// the only path that resolves request identity from the client IP
// (see resolveRequestUser in aiGateway/pipeline.ts) - and it's the
// only inference router that doesn't already run Badger.
const aiGatewayClientIpMiddlewareName = "ai-gateway-client-ip";
const enableAiGatewayClientIpHeader =
config.getRawConfig().server.enable_ai_gateway_client_ip_header;
if (enableAiGatewayClientIpHeader) {
config_output.http.middlewares[aiGatewayClientIpMiddlewareName] = {
plugin: {
badger: {
disableForwardAuth: true,
realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER
}
}
};
const aiGatewayClientIpMiddleware = buildAiGatewayClientIpMiddleware();
const enableAiGatewayClientIpHeader = !!aiGatewayClientIpMiddleware;
if (aiGatewayClientIpMiddleware) {
Object.assign(
config_output.http.middlewares,
aiGatewayClientIpMiddleware
);
}
// Public inference resources: same TLS/cert-resolver handling as
@@ -822,95 +609,41 @@ export async function getTraefikConfig(
const routerName = `${irKey}-router`;
const serviceName = `${irKey}-service`;
let rule: string;
if (ir.wildcard && fullDomain.startsWith("*.")) {
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
} else {
rule = `Host(\`${fullDomain}\`)`;
}
const rule = buildHostRule(fullDomain, ir.wildcard);
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!ir.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = ir.domainCertResolver
? ir.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
ir.preferWildcardCert !== undefined &&
ir.preferWildcardCert !== null
? ir.preferWildcardCert
: globalDefaultPreferWildcard;
const tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
const tls = buildWildcardTls({
fullDomain,
hasSubdomain: !!ir.subdomain,
domainCertResolver: ir.domainCertResolver,
preferWildcardCert: ir.preferWildcardCert
});
const irHeadersMiddlewareName = `${irKey}-headers-middleware`;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[irHeadersMiddlewareName] = {
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
}
}
};
config_output.http.middlewares[irHeadersMiddlewareName] =
buildAiGatewayHostHeaderMiddleware(aiGatewayHost, fullDomain);
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
aiGatewayTrustMiddlewareNameResource,
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
irHeadersMiddlewareName,
...additionalMiddlewares
];
if (ir.ssl) {
config_output.http.routers[routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority: 100
};
}
config_output.http.routers[routerName] = {
entryPoints: [
ir.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
const { routers, services } = buildAiGatewayRouterAndService({
routerName,
serviceName,
rule,
ssl: ir.ssl,
tls,
priority: 100,
...(ir.ssl ? { tls } : {})
};
config_output.http.services[serviceName] = {
loadBalancer: {
servers: [{ url: aiGatewayUrl }]
}
};
routerMiddlewares,
aiGatewayUrl,
redirectHttpsMiddlewareName
});
Object.assign(config_output.http.routers, routers);
Object.assign(config_output.http.services, services);
}
// Private (siteResource) inference resources: routed by their alias
@@ -946,80 +679,49 @@ export async function getTraefikConfig(
const srKey = `inference-sr${sr.siteResourceId}`;
const routerName = `${srKey}-router`;
const serviceName = `${srKey}-service`;
const rule = `Host(\`${fullDomain}\`) && ClientIP(${exitNode.address})`; // restrict to coming from the exit node ip range that the client is connected to
const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to
const domainParts = fullDomain.split(".");
const wildCard =
domainParts.length <= 2
? `*.${domainParts.join(".")}`
: `*.${domainParts.slice(1).join(".")}`;
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const tls = {
certResolver: globalDefaultResolver,
...(globalDefaultPreferWildcard
? { domains: [{ main: wildCard }] }
: {})
};
// siteResource aliases don't have a per-domain cert resolver
// stored, so always fall back to the global defaults.
const tls = buildWildcardTls({
fullDomain,
hasSubdomain: true
});
const srHeadersMiddlewareName = `${srKey}-headers-middleware`;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[srHeadersMiddlewareName] = {
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
}
}
};
config_output.http.middlewares[srHeadersMiddlewareName] =
buildAiGatewayHostHeaderMiddleware(
aiGatewayHost,
fullDomain
);
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
...(enableAiGatewayClientIpHeader
? [aiGatewayClientIpMiddlewareName]
? [AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME]
: []),
aiGatewayTrustMiddlewareNameSiteResource,
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
srHeadersMiddlewareName,
...additionalMiddlewares
];
if (sr.ssl) {
config_output.http.routers[routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority: 200 // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first.
};
}
config_output.http.routers[routerName] = {
entryPoints: [
sr.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
const { routers, services } = buildAiGatewayRouterAndService({
routerName,
serviceName,
rule,
ssl: sr.ssl,
tls,
priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first.
...(sr.ssl ? { tls } : {})
};
config_output.http.services[serviceName] = {
loadBalancer: {
servers: [{ url: aiGatewayUrl }]
}
};
routerMiddlewares,
aiGatewayUrl,
redirectHttpsMiddlewareName
});
Object.assign(config_output.http.routers, routers);
Object.assign(config_output.http.services, services);
}
}
}
+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";
/**
* Create (if configured) and attach a path-rewrite middleware for a
* resource, mutating both config_output.http.middlewares and the
* router's middleware chain. Shared by the OSS and private Traefik config
* generators, which apply it identically.
*/
export function applyPathRewriteMiddleware(
config_output: any,
resourceId: number,
key: string,
path: string | null,
pathMatchType: string | null,
rewritePath: string | null,
rewritePathType: string | null,
routerMiddlewares: string[]
) {
if (
rewritePath === null ||
path === null ||
!pathMatchType ||
!rewritePathType
) {
return;
}
const rewriteMiddlewareName = `rewrite-r${resourceId}-${key}`;
try {
const rewriteResult = createPathRewriteMiddleware(
rewriteMiddlewareName,
path,
pathMatchType,
rewritePath,
rewritePathType
);
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
Object.assign(
config_output.http.middlewares,
rewriteResult.middlewares
);
if (rewriteResult.chain) {
// For chained middlewares (like stripPrefix + addPrefix)
routerMiddlewares.push(...rewriteResult.chain);
} else {
// Single middleware
routerMiddlewares.push(rewriteMiddlewareName);
}
} catch (error) {
logger.error(
`Failed to create path rewrite middleware for resource ${resourceId}: ${error}`
);
}
}
export default function createPathRewriteMiddleware(
middlewareName: string,
path: string,
+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 "./verifyLimits";
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 { db, domainNamespaces } 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,
readPrivateConfigFile
} from "#private/lib/readConfigFile";
import config from "@server/lib/config";
import { readConfigFile as readPublicConfigFile } from "@server/lib/readConfigFile";
import logger from "@server/logger";
export class PrivateConfig {
private rawPrivateConfig!: z.infer<typeof privateConfigSchema>;
@@ -45,6 +48,8 @@ export class PrivateConfig {
this.rawPrivateConfig = parsedPrivateConfig;
this.migrateDeprecatedAcmeConfig(privateEnvironment);
process.env.BRANDING_HIDE_AUTH_LAYOUT_FOOTER =
this.rawPrivateConfig.branding?.hide_auth_layout_footer === true
? "true"
@@ -146,6 +151,38 @@ export class PrivateConfig {
public getRawPrivateConfig() {
return this.rawPrivateConfig;
}
// `flags.enable_acme_cert_sync` and `acme` used to live in the private
// config file. They now live in the public config file. If an operator
// still has them set in the private config and hasn't moved them over to
// the public config, pull them forward so behavior doesn't silently
// change out from under them.
private migrateDeprecatedAcmeConfig(privateEnvironment: any) {
const publicEnvironment: any = readPublicConfigFile();
const rawConfig: any = config.getRawConfig();
if (
privateEnvironment?.flags?.enable_acme_cert_sync !== undefined &&
publicEnvironment?.flags?.enable_acme_cert_sync === undefined
) {
logger.warn(
"`flags.enable_acme_cert_sync` is deprecated in the private config file and has moved to the public config file. Using the value from the private config file for now, but please move it to the public config."
);
rawConfig.flags = rawConfig.flags ?? {};
rawConfig.flags.enable_acme_cert_sync =
this.rawPrivateConfig.flags.enable_acme_cert_sync;
}
if (
privateEnvironment?.acme !== undefined &&
publicEnvironment?.acme === undefined
) {
logger.warn(
"`acme` is deprecated in the private config file and has moved to the public config file. Using the value from the private config file for now, but please move it to the public config."
);
rawConfig.acme = this.rawPrivateConfig.acme;
}
}
}
export const privateConfig = new PrivateConfig();
+9
View File
@@ -109,6 +109,11 @@ export const privateConfigSchema = z
enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional(),
// @deprecated Moved to the public config file as
// `flags.enable_acme_cert_sync` (server/lib/readConfigFile.ts).
// Kept here only so existing private config files keep parsing;
// any value set here is migrated into the public config at
// startup by PrivateConfig (server/private/lib/config.ts).
enable_acme_cert_sync: z.boolean().optional().default(true),
disable_private_http_placeholder: z
.boolean()
@@ -117,6 +122,10 @@ export const privateConfigSchema = z
})
.optional()
.prefault({}),
// @deprecated Moved to the public config file as `acme`
// (server/lib/readConfigFile.ts). Kept here only so existing private
// config files keep parsing; any value set here is migrated into the
// public config at startup by PrivateConfig (server/private/lib/config.ts).
acme: z
.object({
acme_json_path: z
File diff suppressed because it is too large Load Diff
-1
View File
@@ -11,7 +11,6 @@
* This file is not licensed under the AGPLv3.
*/
export * from "./verifyCertificateAccess";
export * from "./verifyRemoteExitNodeAccess";
export * from "./verifyIdpAccess";
export * from "./verifyLoginPageAccess";
@@ -295,8 +295,8 @@ async function disableFeature(
await disableRotateCredentials(orgId);
break;
case TierFeature.MaintencePage:
await disableMaintencePage(orgId);
case TierFeature.MaintenancePage:
await disablemaintenancePage(orgId);
break;
case TierFeature.DevicePosture:
@@ -319,10 +319,6 @@ async function disableFeature(
await disableAutoProvisioning(orgId);
break;
case TierFeature.AdvancedPrivateResources:
await disableAdvancedPrivateResources(orgId);
break;
case TierFeature.FullRbac:
await disableFullRbac(orgId);
break;
@@ -368,13 +364,6 @@ async function disableDeviceApprovals(orgId: string): Promise<void> {
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> {
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 disableMaintencePage(orgId: string): Promise<void> {
async function disablemaintenancePage(orgId: string): Promise<void> {
await db
.update(resources)
.set({
@@ -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.
*/
import * as certificates from "#private/routers/certificates";
import { createStore } from "#private/lib/rateLimitStore";
import * as billing from "#private/routers/billing";
import * as remoteExitNode from "#private/routers/remoteExitNode";
@@ -20,19 +19,16 @@ import * as orgIdp from "#private/routers/orgIdp";
import * as domain from "#private/routers/domain";
import * as auth from "#private/routers/auth";
import * as license from "#private/routers/license";
import * as generateLicense from "./generatedLicense";
import * as generateLicense from "#private/routers/generatedLicense";
import * as logs from "#private/routers/auditLogs";
import * as misc from "#private/routers/misc";
import * as reKey from "#private/routers/re-key";
import * as approval from "#private/routers/approvals";
import * as ssh from "#private/routers/ssh";
import * as user from "#private/routers/user";
import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks";
import * as client from "@server/routers/client";
import * as resource from "#private/routers/resource";
import * as policy from "#private/routers/policy";
import {
@@ -53,7 +49,6 @@ import {
import { ActionsEnum } from "@server/auth/actions";
import {
logActionAudit,
verifyCertificateAccess,
verifyIdpAccess,
verifyLoginPageAccess,
verifyRemoteExitNodeAccess,
@@ -167,32 +162,6 @@ authenticated.get(
orgIdp.listUserAdminOrgIdps
);
authenticated.get(
"/org/:orgId/certificate/:domainId/:domain",
verifyOrgAccess,
verifyCertificateAccess,
verifyUserHasAction(ActionsEnum.getCertificate),
certificates.getCertificate
);
authenticated.get(
"/org/:orgId/batched-certificates",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getCertificate),
certificates.getBatchedCertificates
);
authenticated.post(
"/org/:orgId/certificate/:certId/restart",
verifyValidLicense,
verifyOrgAccess,
verifyCertificateAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.restartCertificate),
logActionAudit(ActionsEnum.restartCertificate),
certificates.restartCertificate
);
if (build === "saas") {
authenticated.post(
"/org/:orgId/billing/create-checkout-session",
@@ -652,17 +621,6 @@ authenticated.put(
reKey.reGenerateExitNodeSecret
);
authenticated.post(
"/org/:orgId/ssh/sign-key",
verifyValidLicense,
verifyValidSubscription(tierMatrix.advancedPrivateResources),
verifyOrgAccess,
verifyLimits,
// verifyUserHasAction(ActionsEnum.signSshKey), // this check happens inside of the function now
// logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata
ssh.signSshKey
);
authenticated.post(
"/user/:userId/add-role/:roleId",
verifyRoleAccess,
@@ -868,18 +826,6 @@ authenticated.get(
healthChecks.getBatchedHealthCheckStatusHistory
);
authenticated.get(
"/client/:clientId/verify-associations-cache",
verifyClientAccess,
client.verifyClientAssociationsCache
);
authenticated.post(
"/client/:clientId/rebuild-associations-cache",
verifyClientAccess,
client.rebuildClientAssociationsCacheRoute
);
authenticated.post(
"/org/:orgId/logs/access/attempt",
verifyOrgAccess,
+1 -1
View File
@@ -15,7 +15,7 @@ import * as orgIdp from "#private/routers/orgIdp";
import * as org from "#private/routers/org";
import * as logs from "#private/routers/auditLogs";
import * as alertEvents from "#private/routers/alertEvents";
import * as certificates from "#private/routers/certificates";
import * as certificates from "@server/routers/certificates";
import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as policy from "#private/routers/policy";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
-7
View File
@@ -17,7 +17,6 @@ import * as orgIdp from "#private/routers/orgIdp";
import * as billing from "#private/routers/billing";
import * as license from "#private/routers/license";
import * as resource from "#private/routers/resource";
import * as ssh from "#private/routers/ssh";
import * as ws from "@server/routers/ws";
import * as browserTarget from "#private/routers/browserGatewayTarget";
@@ -47,12 +46,6 @@ internalRouter.get(`/license/status`, license.getLicenseStatus);
internalRouter.get("/maintenance/info", resource.getMaintenanceInfo);
internalRouter.post(
"/org/:orgId/ssh/sign-key",
verifyUserFromResourceSessionMiddleware,
ssh.signSshKey
);
internalRouter.get(
"/ws/round-trip-message/:messageId",
verifyUserFromResourceSessionMiddleware,
@@ -29,7 +29,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { createCertificate } from "#private/routers/certificates/createCertificate";
import { createCertificate } from "@server/routers/certificates/createCertificate";
import { CreateLoginPageResponse } from "@server/routers/loginPage/types";
@@ -22,7 +22,7 @@ import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { subdomainSchema } from "@server/lib/schemas";
import { createCertificate } from "#private/routers/certificates/createCertificate";
import { createCertificate } from "@server/routers/certificates/createCertificate";
import { UpdateLoginPageResponse } from "@server/routers/loginPage/types";
@@ -85,7 +85,6 @@ export async function updateLoginPage(
const { loginPageId, orgId } = parsedParams.data;
const [existingLoginPage] = await db
.select()
.from(loginPage)
-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";
@@ -1,9 +1,102 @@
import { db, Transaction } from "@server/db";
import { Certificate, certificates, db, domains } from "@server/db";
import logger from "@server/logger";
import { Transaction } from "@server/db";
import { eq, or, and, like } from "drizzle-orm";
/**
* Checks if a certificate exists for the given domain.
* If not, creates a new certificate in 'pending' state.
* Wildcard certs cover subdomains.
*/
export async function createCertificate(
domainId: string,
domain: string,
trx: Transaction | typeof db
) {
return;
const [domainRecord] = await trx
.select()
.from(domains)
.where(eq(domains.domainId, domainId))
.limit(1);
if (!domainRecord) {
throw new Error(`Domain with ID ${domainId} not found`);
}
let existing: Certificate[] = [];
if (domainRecord.type == "ns" || domainRecord.type == "wildcard") {
const domainLevelDown = domain.split(".").slice(1).join(".");
const wildcardPrefixed = `*.${domainLevelDown}`;
existing = await trx
.select()
.from(certificates)
.where(
and(
eq(certificates.domainId, domainId),
or(
eq(certificates.domain, domain),
and(
eq(certificates.wildcard, true),
or(
eq(certificates.domain, domainLevelDown),
eq(certificates.domain, wildcardPrefixed)
)
)
)
)
);
} else {
// For non-NS domains, we only match exact domain names
existing = await trx
.select()
.from(certificates)
.where(
and(
eq(certificates.domainId, domainId),
eq(certificates.domain, domain) // exact match for non-NS domains
)
);
}
if (existing.length > 0) {
logger.info(`Certificate already exists for domain ${domain}`);
return;
}
let domainToWrite = domain;
if (
domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD
domainRecord.preferWildcardCert &&
!domain.startsWith("*.")
) {
// in this case traefik is going to generate a domain one level down so we need to store it that way
const parts = domain.split(".");
if (parts.length > 2) {
domainToWrite = parts.slice(1).join(".");
domainToWrite = `*.${domainToWrite}`;
}
} else if (domainRecord.type == "ns") {
if (domain == domainRecord.baseDomain) {
domainToWrite = domainRecord.baseDomain;
} else {
const parts = domain.split(".");
if (parts.length > 2) {
domainToWrite = parts.slice(1).join(".");
}
}
}
// No cert found, create a new one in pending state
await trx.insert(certificates).values({
domain: domainToWrite,
domainId,
wildcard:
domainRecord.type == "ns" ||
(domainRecord.type == "wildcard" &&
domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains
status: "pending",
updatedAt: Math.floor(Date.now() / 1000),
createdAt: Math.floor(Date.now() / 1000)
});
}
@@ -1,15 +1,3 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { certificates, db, domainNamespaces, domains, orgDomains } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
@@ -1,16 +1,3 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { certificates, db, domains } from "@server/db";
+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 response from "@server/lib/response";
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 { z } from "zod";
import { pushCertUpdateToAffectedNewts } from "#private/lib/acmeCertSync";
import { pushCertUpdateToAffectedNewts } from "@server/lib/acmeCertSync";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -65,4 +52,4 @@ export async function syncCertToNewts(
)
);
}
}
}
+51 -11
View File
@@ -20,6 +20,7 @@ import * as logs from "./auditLogs";
import * as launcher from "./launcher";
import * as newt from "./newt";
import * as olm from "./olm";
import * as ssh from "./ssh";
import * as serverInfo from "./serverInfo";
import HttpCode from "@server/types/HttpCode";
import {
@@ -49,19 +50,21 @@ import {
verifyAiProviderAccess,
verifyAiModelAccess,
verifyAiBudgetAccess,
verifyVirtualApiKeyAccess
verifyVirtualApiKeyAccess,
logActionAudit,
verifyCertificateAccess
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import createHttpError from "http-errors";
import { build } from "@server/build";
import { createStore } from "#dynamic/lib/rateLimitStore";
import { logActionAudit } from "#dynamic/middlewares";
import { checkRoundTripMessage } from "./ws";
import * as labels from "@server/routers/labels";
import * as aiProvider from "@server/routers/aiProvider";
import * as aiBudget from "@server/routers/aiBudget";
import * as virtualApiKey from "@server/routers/virtualApiKey";
import * as certificates from "@server/routers/certificates";
// Root routes
export const unauthenticated = Router();
@@ -1735,15 +1738,6 @@ authenticated.get(
virtualApiKey.listVirtualApiKeys
);
authenticated.post(
"/org/:orgId/virtual-api-keys/email-identity-keys",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getVirtualApiKey),
virtualApiKey.emailIdentityKeysRateLimit,
logActionAudit(ActionsEnum.getVirtualApiKey),
virtualApiKey.emailIdentityKeys
);
authenticated.get(
"/org/:orgId/my-virtual-api-keys",
verifyOrgAccess,
@@ -1863,6 +1857,52 @@ authenticated.put(
labels.detachLabelFromItem
);
authenticated.post(
"/org/:orgId/ssh/sign-key",
verifyOrgAccess,
verifyLimits,
// verifyUserHasAction(ActionsEnum.signSshKey), // this check happens inside of the function now
// logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata
ssh.signSshKey
);
authenticated.get(
"/client/:clientId/verify-associations-cache",
verifyClientAccess,
client.verifyClientAssociationsCache
);
authenticated.post(
"/client/:clientId/rebuild-associations-cache",
verifyClientAccess,
client.rebuildClientAssociationsCacheRoute
);
authenticated.get(
"/org/:orgId/certificate/:domainId/:domain",
verifyOrgAccess,
verifyCertificateAccess,
verifyUserHasAction(ActionsEnum.getCertificate),
certificates.getCertificate
);
authenticated.get(
"/org/:orgId/batched-certificates",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getCertificate),
certificates.getBatchedCertificates
);
authenticated.post(
"/org/:orgId/certificate/:certId/restart",
verifyOrgAccess,
verifyCertificateAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.restartCertificate),
logActionAudit(ActionsEnum.restartCertificate),
certificates.restartCertificate
);
// Auth routes
export const authRouter = Router();
unauthenticated.use("/auth", authRouter);
-9
View File
@@ -1769,15 +1769,6 @@ authenticated.get(
virtualApiKey.listVirtualApiKeys
);
authenticated.post(
"/org/:orgId/virtual-api-keys/email-identity-keys",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.getVirtualApiKey),
virtualApiKey.emailIdentityKeysRateLimit,
logActionAudit(ActionsEnum.getVirtualApiKey),
virtualApiKey.emailIdentityKeys
);
authenticated.get(
"/virtual-api-key/:virtualApiKeyId",
verifyApiKeyVirtualApiKeyAccess,
+11 -4
View File
@@ -1,15 +1,17 @@
import { Router } from "express";
import * as gerbil from "@server/routers/gerbil";
import * as traefik from "@server/routers/traefik";
import * as resource from "./resource";
import * as badger from "./badger";
import * as resource from "@server/routers/resource";
import * as badger from "@server/routers/badger";
import * as auth from "@server/routers/auth";
import * as supporterKey from "@server/routers/supporterKey";
import * as idp from "@server/routers/idp";
import * as ssh from "@server/routers/ssh";
import HttpCode from "@server/types/HttpCode";
import {
verifyResourceAccess,
verifySessionUserMiddleware
verifySessionUserMiddleware,
verifyUserFromResourceSessionMiddleware
} from "@server/middlewares";
// Root routes
@@ -42,6 +44,12 @@ internalRouter.get("/idp", idp.listIdps);
internalRouter.get("/idp/:idpId", idp.getIdp);
internalRouter.post(
"/org/:orgId/ssh/sign-key",
verifyUserFromResourceSessionMiddleware,
ssh.signSshKey
);
// Gerbil routes
const gerbilRouter = Router();
internalRouter.use("/gerbil", gerbilRouter);
@@ -63,4 +71,3 @@ internalRouter.use("/badger", badgerRouter);
badgerRouter.post("/verify-session", badger.verifyResourceSession);
badgerRouter.post("/exchange-session", badger.exchangeSession);
+2 -17
View File
@@ -24,14 +24,14 @@ import logger from "@server/logger";
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { createCertificate } from "@server/routers/certificates";
import {
validateAndConstructDomain,
checkWildcardDomainConflict
} from "@server/lib/domainUtils";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
getUniqueResourceName,
getUniqueResourcePolicyName
@@ -454,21 +454,6 @@ async function createHttpResource(
}
}
if (
["ssh", "rdp", "vnc"].includes(effectiveMode) &&
!isLicensedOrSubscribed(
orgId!,
tierMatrix[TierFeature.AdvancedPublicResources]
)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Your current subscription does not support browser gateway resources. Please upgrade to access this feature."
)
);
}
// Validate domain and construct full domain
const domainResult = await validateAndConstructDomain(
domainId,
+1 -1
View File
@@ -38,7 +38,7 @@ import {
} from "@server/lib/schemas";
import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { createCertificate } from "@server/routers/certificates/createCertificate";
import {
validateAndConstructDomain,
checkWildcardDomainConflict
+1 -1
View File
@@ -135,7 +135,7 @@ export async function createRole(
const isLicensedSshPam = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
tierMatrix.roleBasedSSHControls
);
const roleInsertValues: Record<string, unknown> = {
name: roleData.name,
+1 -1
View File
@@ -144,7 +144,7 @@ export async function updateRole(
const isLicensedSshPam = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
tierMatrix.roleBasedSSHControls
);
if (!isLicensedSshPam) {
delete updateData.sshSudoMode;
@@ -10,8 +10,7 @@ import {
SiteResource,
siteResources,
sites,
userSiteResources,
primaryDb
userSiteResources
} from "@server/db";
import { getUniqueSiteResourceName } from "@server/db/names";
import {
@@ -19,8 +18,6 @@ import {
isIpInCidr,
portRangeStringSchema
} from "@server/lib/ip";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
@@ -35,7 +32,7 @@ import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { createCertificate } from "@server/routers/certificates/createCertificate";
import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService";
import { LimitId } from "@server/lib/billing";
@@ -408,21 +405,6 @@ export async function createSiteResource(
}
}
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix[TierFeature.AdvancedPrivateResources]
);
if (!hasHttpFeature) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"HTTP private resources are not included in your current plan. Please upgrade."
)
);
}
}
// Verify the site exists and belongs to the org
const sitesToAssign = await db
.select()
@@ -557,20 +539,6 @@ export async function createSiteResource(
}
}
const isLicensedSshPam = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (mode == "ssh" && !isLicensedSshPam) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"SSH private resources are not included in your current plan. Please upgrade."
)
);
}
let updatedNiceId = niceId;
if (!niceId) {
updatedNiceId = await getUniqueSiteResourceName(orgId);
@@ -646,13 +614,13 @@ export async function createSiteResource(
fullDomain,
requiresExitNodeConnection: mode === "inference" // in the future we might want to have different modes that do this
};
if (isLicensedSshPam) {
if (authDaemonPort !== undefined)
insertValues.authDaemonPort = authDaemonPort;
if (authDaemonMode !== undefined)
insertValues.authDaemonMode = authDaemonMode;
if (pamMode !== undefined) insertValues.pamMode = pamMode;
}
if (authDaemonPort !== undefined)
insertValues.authDaemonPort = authDaemonPort;
if (authDaemonMode !== undefined)
insertValues.authDaemonMode = authDaemonMode;
if (pamMode !== undefined) insertValues.pamMode = pamMode;
[newSiteResource] = await trx
.insert(siteResources)
.values(insertValues)
@@ -10,8 +10,6 @@ import {
sites,
userSiteResources
} from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import response from "@server/lib/response";
import { eq, and, ne, inArray } from "drizzle-orm";
@@ -362,26 +360,6 @@ export async function updateSiteResource(
);
}
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
existingSiteResource.orgId,
tierMatrix[TierFeature.AdvancedPrivateResources]
);
if (!hasHttpFeature) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"HTTP private resources are not included in your current plan. Please upgrade."
)
);
}
}
const isLicensedSshPam = await isLicensedOrSubscribed(
existingSiteResource.orgId,
tierMatrix.advancedPrivateResources
);
const [org] = await db
.select()
.from(orgs)
@@ -541,10 +519,9 @@ export async function updateSiteResource(
await db.transaction(async (trx) => {
// Update the site resource
const sshPamSet =
isLicensedSshPam &&
(authDaemonPort !== undefined ||
authDaemonMode !== undefined ||
pamMode !== undefined)
authDaemonPort !== undefined ||
authDaemonMode !== undefined ||
pamMode !== undefined
? {
...(authDaemonPort !== undefined && {
authDaemonPort
+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 { randomInt } from "crypto";
import { z } from "zod";
@@ -35,8 +22,6 @@ import {
SiteResource
} from "@server/db";
import { logAccessAudit } from "#private/lib/logAccessAudit";
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -163,19 +148,6 @@ export async function signSshKey(
);
}
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"SSH key signing requires a paid plan"
)
);
}
// Get and decrypt the org's CA keys
const caKeys = await getOrgCAKeys(
orgId,
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, orgs, userOrgs, virtualApiKeys } from "@server/db";
import { db, userOrgs, virtualApiKeys } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -18,10 +18,6 @@ import {
} from "@server/lib/virtualApiKey";
import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
import { createVirtualApiKeyBodySchema } from "@server/routers/virtualApiKey/validation";
import {
resolveVirtualApiKeyEmailRecipients,
sendVirtualApiKeyEmails
} from "@server/lib/sendVirtualApiKeyEmail";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
@@ -82,10 +78,7 @@ export async function createVirtualApiKey(
userId,
allResources,
resourceIds,
validForSeconds,
sendEmail: doEmail,
sendToAttributedUser,
emails
validForSeconds
} = parsedBody.data;
if (req.user && orgId && orgId !== req.userOrgId) {
@@ -128,18 +121,6 @@ export async function createVirtualApiKey(
);
}
const emailRecipients = await resolveVirtualApiKeyEmailRecipients({
sendEmail: doEmail,
sendToAttributedUser,
userId,
emails
});
if (!emailRecipients.ok) {
return next(
createHttpError(HttpCode.BAD_REQUEST, emailRecipients.message)
);
}
const minted = mintVirtualApiKeySecret();
const expiresAt = validForSeconds
? createDate(new TimeSpan(validForSeconds, "s")).getTime()
@@ -175,24 +156,6 @@ export async function createVirtualApiKey(
return row;
});
if (emailRecipients.recipients.length > 0) {
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
await sendVirtualApiKeyEmails({
recipients: emailRecipients.recipients,
orgName: org?.name || orgId,
orgId,
keyName: created.name,
virtualApiKeyId: created.virtualApiKeyId,
secret: minted.secret,
allResources: created.allResources
});
}
return response<CreateOrEditVirtualApiKeyResponse>(res, {
data: {
virtualApiKey: {
@@ -1,264 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, orgs, roles, userOrgRoles, userOrgs, users } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, inArray } from "drizzle-orm";
import config from "@server/lib/config";
import { getOrCreateUserVirtualApiKey } from "@server/lib/virtualApiKey";
import {
sendVirtualApiKeyEmails,
listOrgInferenceGatewayUrls
} from "@server/lib/sendVirtualApiKeyEmail";
import type { EmailIdentityKeysResponse } from "@server/routers/virtualApiKey/types";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import { createStore } from "#dynamic/lib/rateLimitStore";
const EMAIL_IDENTITY_KEYS_WINDOW_MINUTES = 15;
const EMAIL_IDENTITY_KEYS_MAX = 3;
export const emailIdentityKeysRateLimit = rateLimit({
windowMs: EMAIL_IDENTITY_KEYS_WINDOW_MINUTES * 60 * 1000,
max: EMAIL_IDENTITY_KEYS_MAX,
keyGenerator: (req) => {
const actor =
req.user?.userId ||
req.apiKey?.apiKeyId ||
ipKeyGenerator(req.ip || "");
const orgId =
typeof req.params.orgId === "string" ? req.params.orgId : "";
return `emailIdentityKeys:${actor}:${orgId}`;
},
handler: (_req, _res, next) => {
const message = `You can only email identity keys ${EMAIL_IDENTITY_KEYS_MAX} times every ${EMAIL_IDENTITY_KEYS_WINDOW_MINUTES} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
},
store: createStore()
});
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const bodySchema = z
.strictObject({
sendToAll: z.boolean().optional().default(false),
userIds: z.array(z.string().nonempty()).optional().default([]),
roleIds: z.array(z.number().int().positive()).optional().default([])
})
.superRefine((data, ctx) => {
if (
!data.sendToAll &&
data.userIds.length === 0 &&
data.roleIds.length === 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Select at least one user or role, or send to all users",
path: ["userIds"]
});
}
});
registry.registerPath({
method: "post",
path: "/org/{orgId}/virtual-api-keys/email-identity-keys",
description:
"Email identity virtual API keys to selected organization members and roles, or to all members.",
tags: [OpenAPITags.VirtualApiKey],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function emailIdentityKeys(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
if (!config.getRawConfig().email) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email is not configured on this server"
)
);
}
const { orgId } = parsedParams.data;
const { sendToAll, userIds, roleIds } = parsedBody.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const uniqueUserIds = [...new Set(userIds)];
const uniqueRoleIds = [...new Set(roleIds)];
if (!sendToAll && uniqueRoleIds.length > 0) {
const orgRoles = await db
.select({ roleId: roles.roleId })
.from(roles)
.where(
and(
eq(roles.orgId, orgId),
inArray(roles.roleId, uniqueRoleIds)
)
);
if (orgRoles.length !== uniqueRoleIds.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"One or more roles are invalid for this organization"
)
);
}
}
let targetUserIds: string[] | null = null;
if (!sendToAll) {
let roleUserIds: string[] = [];
if (uniqueRoleIds.length > 0) {
const roleMembers = await db
.select({ userId: userOrgRoles.userId })
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.orgId, orgId),
inArray(userOrgRoles.roleId, uniqueRoleIds)
)
);
roleUserIds = roleMembers.map((row) => row.userId);
}
targetUserIds = [...new Set([...uniqueUserIds, ...roleUserIds])];
if (targetUserIds.length === 0) {
return response<EmailIdentityKeysResponse>(res, {
data: { sent: 0, skipped: 0 },
success: true,
error: false,
message: "Identity keys emailed successfully",
status: HttpCode.OK
});
}
}
const memberConditions = [eq(userOrgs.orgId, orgId)];
if (targetUserIds) {
memberConditions.push(inArray(users.userId, targetUserIds));
}
const members = await db
.select({ user: users })
.from(users)
.innerJoin(userOrgs, eq(userOrgs.userId, users.userId))
.where(and(...memberConditions));
if (!sendToAll && uniqueUserIds.length > 0) {
const foundIds = new Set(members.map((row) => row.user.userId));
if (uniqueUserIds.some((id) => !foundIds.has(id))) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"One or more users are not members of this organization"
)
);
}
}
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
const orgName = org?.name || orgId;
const gatewayUrls = await listOrgInferenceGatewayUrls(orgId);
let sent = 0;
let skipped = 0;
for (const { user } of members) {
if (!user.email) {
skipped += 1;
continue;
}
const { key, secret } = await getOrCreateUserVirtualApiKey({
orgId,
user,
createdByUserId: req.user?.userId ?? null
});
await sendVirtualApiKeyEmails({
recipients: [user.email],
orgName,
orgId,
keyName: key.name,
virtualApiKeyId: key.virtualApiKeyId,
secret,
allResources: true,
isIdentityKey: true,
accountLabel: user.email || user.name || user.username,
gatewayUrls
});
sent += 1;
}
return response<EmailIdentityKeysResponse>(res, {
data: { sent, skipped },
success: true,
error: false,
message: "Identity keys emailed successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
-1
View File
@@ -5,5 +5,4 @@ export * from "./getVirtualApiKey";
export * from "./getMyVirtualApiKey";
export * from "./updateVirtualApiKey";
export * from "./deleteVirtualApiKey";
export * from "./emailIdentityKeys";
export * from "./types";
-5
View File
@@ -28,8 +28,3 @@ export type ListMyVirtualApiKeysResponse = {
export type GetMyVirtualApiKeyResponse = {
virtualApiKey: VirtualApiKeyWithResources;
};
export type EmailIdentityKeysResponse = {
sent: number;
skipped: number;
};
@@ -2,7 +2,6 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
orgs,
userOrgs,
virtualApiKeyResources,
virtualApiKeys
@@ -17,16 +16,11 @@ import { and, eq } from "drizzle-orm";
import { createDate, TimeSpan } from "oslo";
import {
assertManualKeyResourcesInOrg,
decryptVirtualApiKeyToken,
replaceVirtualApiKeyResources,
toPublicVirtualApiKey
} from "@server/lib/virtualApiKey";
import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
import { updateVirtualApiKeyBodySchema } from "@server/routers/virtualApiKey/validation";
import {
resolveVirtualApiKeyEmailRecipients,
sendVirtualApiKeyEmails
} from "@server/lib/sendVirtualApiKeyEmail";
const paramsSchema = z.strictObject({
virtualApiKeyId: z.string().nonempty()
@@ -152,20 +146,6 @@ export async function updateVirtualApiKey(
}
}
const nextUserId =
body.userId !== undefined ? body.userId : existing.userId;
const emailRecipients = await resolveVirtualApiKeyEmailRecipients({
sendEmail: body.sendEmail,
sendToAttributedUser: body.sendToAttributedUser,
userId: nextUserId,
emails: body.emails
});
if (!emailRecipients.ok) {
return next(
createHttpError(HttpCode.BAD_REQUEST, emailRecipients.message)
);
}
const updates: Partial<typeof virtualApiKeys.$inferInsert> = {};
if (body.name !== undefined) {
@@ -212,24 +192,6 @@ export async function updateVirtualApiKey(
return row;
});
if (emailRecipients.recipients.length > 0) {
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, existing.orgId))
.limit(1);
await sendVirtualApiKeyEmails({
recipients: emailRecipients.recipients,
orgName: org?.name || existing.orgId,
orgId: existing.orgId,
keyName: updated.name,
virtualApiKeyId: updated.virtualApiKeyId,
secret: decryptVirtualApiKeyToken(updated.token),
allResources: updated.allResources
});
}
const resourceRows = await db
.select({ resourceId: virtualApiKeyResources.resourceId })
.from(virtualApiKeyResources)
+10 -65
View File
@@ -4,43 +4,6 @@ export const virtualApiKeyResourceIdsSchema = z
.array(z.coerce.number().int().positive())
.optional();
const virtualApiKeyEmailFieldsSchema = {
sendEmail: z.boolean().optional().default(false),
sendToAttributedUser: z.boolean().optional().default(false),
emails: z.array(z.email().toLowerCase()).max(20).optional().default([])
};
function refineVirtualApiKeyEmailFields(
data: {
sendEmail: boolean;
sendToAttributedUser: boolean;
emails: string[];
userId?: string | null;
},
ctx: z.RefinementCtx
) {
if (!data.sendEmail) {
return;
}
if (!data.sendToAttributedUser && data.emails.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Select the associated user or add at least one email address",
path: ["sendEmail"]
});
}
if (data.sendToAttributedUser && !data.userId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Associate a user to email the key to that user",
path: ["sendToAttributedUser"]
});
}
}
export const createVirtualApiKeyBodySchema = z
.strictObject({
name: z.string().nonempty(),
@@ -48,8 +11,7 @@ export const createVirtualApiKeyBodySchema = z
userId: z.string().optional().nullable(),
allResources: z.boolean().optional().default(false),
resourceIds: virtualApiKeyResourceIdsSchema,
validForSeconds: z.int().positive().optional(),
...virtualApiKeyEmailFieldsSchema
validForSeconds: z.int().positive().optional()
})
.refine(
(data) => data.allResources || (data.resourceIds?.length ?? 0) > 0,
@@ -58,30 +20,13 @@ export const createVirtualApiKeyBodySchema = z
"Select at least one public inference resource, or enable all public inference resources",
path: ["resourceIds"]
}
)
.superRefine(refineVirtualApiKeyEmailFields);
);
export const updateVirtualApiKeyBodySchema = z
.strictObject({
name: z.string().nonempty().optional(),
description: z.string().optional().nullable(),
userId: z.string().optional().nullable(),
allResources: z.boolean().optional(),
resourceIds: virtualApiKeyResourceIdsSchema,
validForSeconds: z.int().positive().optional().nullable(),
...virtualApiKeyEmailFieldsSchema
})
.superRefine((data, ctx) => {
if (!data.sendEmail) {
return;
}
if (!data.sendToAttributedUser && data.emails.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Select the associated user or add at least one email address",
path: ["sendEmail"]
});
}
});
export const updateVirtualApiKeyBodySchema = z.strictObject({
name: z.string().nonempty().optional(),
description: z.string().optional().nullable(),
userId: z.string().optional().nullable(),
allResources: z.boolean().optional(),
resourceIds: virtualApiKeyResourceIdsSchema,
validForSeconds: z.int().positive().optional().nullable()
});
@@ -35,10 +35,6 @@ import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceHttpPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const { isPaidUser } = usePaidStatus();
const httpSectionDisabled = !isPaidUser(
tierMatrix.advancedPrivateResources
);
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
@@ -120,7 +116,7 @@ export default function PrivateResourceHttpPage() {
)}
orgId={siteResource.orgId}
watch={asAnyWatch(form.watch)}
disabled={httpSectionDisabled}
disabled={false}
siteResourceId={siteResource.id}
/>
</SettingsFormCell>
@@ -135,7 +131,6 @@ export default function PrivateResourceHttpPage() {
type="submit"
form="private-resource-http-form"
loading={saveLoading}
disabled={httpSectionDisabled}
>
{t("saveSettings")}
</Button>
@@ -12,16 +12,13 @@ import {
SettingsFormGrid
} from "@app/components/Settings";
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import {
createSshFormSchema,
inferSshPamMode
} from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@@ -39,8 +36,6 @@ import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceSshPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const { isPaidUser } = usePaidStatus();
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const isNative = siteResource.authDaemonMode === "native";
const [sshServerMode] = useState<"standard" | "native">(
isNative ? "native" : "standard"
@@ -150,7 +145,6 @@ export default function PrivateResourceSshPage() {
return (
<SettingsContainer>
<PaidFeaturesAlert tiers={tierMatrix.advancedPrivateResources} />
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -161,68 +155,56 @@ export default function PrivateResourceSshPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={sshSectionDisabled}
className={
sshSectionDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SshServerSettingsFields
idPrefix="private-ssh-edit"
pamMode={pamMode}
standardDaemonLocation={
standardDaemonLocation
}
authDaemonPort={authDaemonPort}
onPamModeChange={handlePamModeChange}
onStandardDaemonLocationChange={
handleDaemonLocationChange
}
onAuthDaemonPortChange={(value) =>
form.setValue(
"authDaemonPort",
value,
{ shouldValidate: true }
)
}
authDaemonPortError={
form.formState.errors.authDaemonPort
?.message
}
sshServerMode={sshServerMode}
serverModeDisplay="badge"
/>
<PrivateResourceSshFields
control={asAnyControl(form.control)}
setValue={asAnySetValue(form.setValue)}
watch={asAnyWatch(form.watch)}
orgId={siteResource.orgId}
selectedSites={selectedSites}
onSelectedSitesChange={setSelectedSites}
showSshSettings={false}
embedInParentGrid
showPaidFeaturesAlert={false}
isNativeSsh={isNative}
/>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SshServerSettingsFields
idPrefix="private-ssh-edit"
pamMode={pamMode}
standardDaemonLocation={
standardDaemonLocation
}
authDaemonPort={authDaemonPort}
onPamModeChange={handlePamModeChange}
onStandardDaemonLocationChange={
handleDaemonLocationChange
}
onAuthDaemonPortChange={(value) =>
form.setValue("authDaemonPort", value, {
shouldValidate: true
})
}
authDaemonPortError={
form.formState.errors.authDaemonPort
?.message
}
sshServerMode={sshServerMode}
serverModeDisplay="badge"
/>
<PrivateResourceSshFields
control={asAnyControl(form.control)}
setValue={asAnySetValue(form.setValue)}
watch={asAnyWatch(form.watch)}
orgId={siteResource.orgId}
selectedSites={selectedSites}
onSelectedSitesChange={setSelectedSites}
showSshSettings={false}
embedInParentGrid
isNativeSsh={isNative}
/>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<form action={formAction}>
<Button type="submit" loading={saveLoading}>
{t("saveSettings")}
</Button>
</form>
</SettingsSectionFooter>
</Form>
</fieldset>
<SettingsSectionFooter>
<form action={formAction}>
<Button type="submit" loading={saveLoading}>
{t("saveSettings")}
</Button>
</form>
</SettingsSectionFooter>
</Form>
</SettingsSection>
</SettingsContainer>
);
@@ -16,7 +16,6 @@ import {
type DescribedSelectOption
} from "@app/components/DescribedSelect";
import DomainPicker from "@app/components/DomainPicker";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { Button } from "@app/components/ui/button";
import {
Form,
@@ -30,7 +29,6 @@ import {
import { Input } from "@app/components/ui/input";
import type { Selectedsite } from "@app/components/site-selector";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
@@ -77,12 +75,6 @@ export default function CreatePrivateResourcePage() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const orgId = params.orgId as string;
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
const { isPaidUser } = usePaidStatus();
const httpSectionDisabled = !isPaidUser(
tierMatrix.advancedPrivateResources
);
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const [isSubmitting, startTransition] = useTransition();
const siteIdParam = searchParams.get("siteId");
@@ -158,20 +150,16 @@ export default function CreatePrivateResourcePage() {
title: t("createInternalResourceDialogModeCidr"),
description: t("privateResourceTypeCidrDescription")
},
...(!disableEnterpriseFeatures
? [
{
value: "http" as const,
title: t("createInternalResourceDialogModeHttp"),
description: t("privateResourceTypeHttpDescription")
},
{
value: "ssh" as const,
title: t("createInternalResourceDialogModeSsh"),
description: t("privateResourceTypeSshDescription")
}
]
: []),
{
value: "http" as const,
title: t("createInternalResourceDialogModeHttp"),
description: t("privateResourceTypeHttpDescription")
},
{
value: "ssh" as const,
title: t("createInternalResourceDialogModeSsh"),
description: t("privateResourceTypeSshDescription")
},
{
value: "inference" as const,
title: t("createInternalResourceDialogModeInference"),
@@ -179,11 +167,6 @@ export default function CreatePrivateResourcePage() {
}
];
const submitDisabled =
isSubmitting ||
(mode === "http" && httpSectionDisabled) ||
(mode === "ssh" && sshSectionDisabled);
function onSubmit(values: FormValues) {
startTransition(async () => {
try {
@@ -467,10 +450,7 @@ export default function CreatePrivateResourcePage() {
)}
watch={asAnyWatch(form.watch)}
labelPrefix="create"
disabled={
mode === "ssh" &&
sshSectionDisabled
}
disabled={false}
/>
</SettingsFormCell>
)}
@@ -584,9 +564,6 @@ export default function CreatePrivateResourcePage() {
{/* HTTP configuration */}
{mode === "http" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("httpSettings")}
@@ -597,62 +574,44 @@ export default function CreatePrivateResourcePage() {
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={httpSectionDisabled}
className={
httpSectionDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={orgId}
selectedSites={
selectedSites
}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourceHttpFields
control={asAnyControl(
form.control
)}
setValue={asAnySetValue(
form.setValue
)}
orgId={orgId}
watch={asAnyWatch(
form.watch
)}
disabled={
httpSectionDisabled
}
labelPrefix="create"
hideDomainPicker
hidePaidFeaturesAlert
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourceHttpFields
control={asAnyControl(
form.control
)}
setValue={asAnySetValue(
form.setValue
)}
orgId={orgId}
watch={asAnyWatch(form.watch)}
disabled={true}
labelPrefix="create"
hideDomainPicker
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{/* SSH server */}
{mode === "ssh" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("sshSettings")}
@@ -661,37 +620,23 @@ export default function CreatePrivateResourcePage() {
{t("sshServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={sshSectionDisabled}
className={
sshSectionDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<PrivateResourceSshFields
control={asAnyControl(form.control)}
setValue={asAnySetValue(
form.setValue
)}
watch={asAnyWatch(form.watch)}
orgId={orgId}
disabled={sshSectionDisabled}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
labelPrefix="create"
showSshSettings={true}
layout="wizard"
showPaidFeaturesAlert={false}
hideAlias
/>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<PrivateResourceSshFields
control={asAnyControl(form.control)}
setValue={asAnySetValue(form.setValue)}
watch={asAnyWatch(form.watch)}
orgId={orgId}
disabled={false}
selectedSites={selectedSites}
onSelectedSitesChange={setSelectedSites}
labelPrefix="create"
showSshSettings={true}
layout="wizard"
hideAlias
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
@@ -776,7 +721,7 @@ export default function CreatePrivateResourcePage() {
<Button
type="submit"
form="create-private-resource-form"
disabled={submitDisabled}
disabled={isSubmitting}
loading={isSubmitting}
>
{t("createInternalResourceDialogCreateResource")}
@@ -161,7 +161,7 @@ export default function ResourceMaintenancePage() {
return null;
}
const isMaintenanceDisabled = !isPaidUser(tierMatrix.maintencePage);
const isMaintenanceDisabled = !isPaidUser(tierMatrix.maintenancePage);
const maintenanceModeTypeOptions: StrategyOption<
"automatic" | "forced"
@@ -180,7 +180,7 @@ export default function ResourceMaintenancePage() {
return (
<>
<PaidFeaturesAlert tiers={tierMatrix.maintencePage} />
<PaidFeaturesAlert tiers={tierMatrix.maintenancePage} />
<div
className={
isMaintenanceDisabled
@@ -55,11 +55,7 @@ export default function RdpSettingsPage(props: {
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "rdp"],
@@ -75,14 +71,11 @@ export default function RdpSettingsPage(props: {
return (
<SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<RdpServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
disabled={disabled}
disabled={false}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
@@ -75,11 +75,7 @@ export default function SshSettingsPage(props: {
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "ssh"],
@@ -95,14 +91,11 @@ export default function SshSettingsPage(props: {
return (
<SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<SshServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
disabled={disabled}
disabled={false}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
@@ -55,11 +55,7 @@ export default function VncSettingsPage(props: {
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "vnc"],
@@ -75,14 +71,11 @@ export default function VncSettingsPage(props: {
return (
<SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<VncServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
disabled={disabled}
disabled={true}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
@@ -239,14 +239,6 @@ export default function Page() {
// Resource type state
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)
const [targets, setTargets] = useState<LocalTarget[]>([]);
const [selectedProviders, setSelectedProviders] = useState<
@@ -1056,14 +1048,6 @@ export default function Page() {
{/* SSH Server Section */}
{resourceType === "ssh" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("sshServer")}
@@ -1072,14 +1056,7 @@ export default function Page() {
{t("sshServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
@@ -1318,21 +1295,12 @@ export default function Page() {
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection>
)}
{/* RDP Server Section */}
{resourceType === "rdp" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("rdpServer")}
@@ -1341,14 +1309,6 @@ export default function Page() {
{t("rdpServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...bgTargetForm}>
@@ -1365,21 +1325,12 @@ export default function Page() {
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection>
)}
{/* VNC Server Section */}
{resourceType === "vnc" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("vncServer")}
@@ -1388,14 +1339,7 @@ export default function Page() {
{t("vncServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...bgTargetForm}>
@@ -1412,7 +1356,6 @@ export default function Page() {
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection>
)}
@@ -1527,7 +1470,6 @@ export default function Page() {
loading={createLoading}
disabled={
!areAllTargetsValid() ||
browserGatewayDisabled ||
createLoading
}
>
@@ -1,16 +0,0 @@
import type { Metadata } from "next";
import IdentityKeysSplash from "@app/components/IdentityKeysSplash";
export const metadata: Metadata = {
title: "Identity Keys"
};
type IdentityKeysPageProps = {
params: Promise<{ orgId: string }>;
};
export default async function IdentityKeysPage(props: IdentityKeysPageProps) {
const params = await props.params;
return <IdentityKeysSplash orgId={params.orgId} />;
}
@@ -1,142 +0,0 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import VirtualApiKeysTable, {
type VirtualApiKeyRow
} from "@app/components/VirtualApiKeysTable";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
import type { ListVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
import type { ListUsersResponse } from "@server/routers/user";
import type { ListResourcesResponse } from "@server/routers/resource";
export const metadata: Metadata = {
title: "Virtual Keys"
};
type VirtualApiKeysTablePageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function VirtualApiKeysTablePage(
props: VirtualApiKeysTablePageProps
) {
const params = await props.params;
const cookieHeader = await authCookieHeader();
const t = await getTranslations();
let keys: ListVirtualApiKeysResponse["virtualApiKeys"] = [];
let users: {
userId: string;
email: string | null;
name: string | null;
username: string | null;
}[] = [];
let resources: {
resourceId: number;
name: string;
niceId: string;
}[] = [];
try {
const [keysRes, usersRes, resourcesRes] = await Promise.all([
internal.get<AxiosResponse<ListVirtualApiKeysResponse>>(
`/org/${params.orgId}/virtual-api-keys?page=1&pageSize=1000`,
cookieHeader
),
internal.get<AxiosResponse<ListUsersResponse>>(
`/org/${params.orgId}/users?page=1&pageSize=1000`,
cookieHeader
),
internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources?page=1&pageSize=1000`,
cookieHeader
)
]);
keys = keysRes.data.data.virtualApiKeys ?? [];
users = (usersRes.data.data.users ?? []).map((u) => ({
userId: u.id,
email: u.email ?? null,
name: u.name ?? null,
username: u.username ?? null
}));
resources = (resourcesRes.data.data.resources ?? []).map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId
}));
} catch {
// leave empty; page still renders
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
cookieHeader
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources`);
}
const userById = new Map(users.map((u) => [u.userId, u]));
const resourceById = new Map(resources.map((r) => [r.resourceId, r]));
const rows: VirtualApiKeyRow[] = keys.map((key) => {
const user = key.userId ? userById.get(key.userId) : undefined;
const keyResources = key.resourceIds
.map((id) => resourceById.get(id))
.filter(Boolean) as {
resourceId: number;
name: string;
niceId: string;
}[];
const resourceNames = key.allResources
? t("virtualApiKeysAllResources")
: keyResources.map((r) => r.name).join(", ") ||
t("virtualApiKeysNoResources");
return {
virtualApiKeyId: key.virtualApiKeyId,
orgId: key.orgId,
kind: key.kind,
userId: key.userId,
name: key.name,
description: key.description,
lastChars: key.lastChars,
allResources: key.allResources,
expiresAt: key.expiresAt,
lastUsedAt: key.lastUsedAt,
createdAt: key.createdAt,
createdByUserId: key.createdByUserId,
resourceIds: key.resourceIds,
userName: user?.name ?? null,
username: user?.username ?? null,
userEmail: user?.email ?? null,
resourceNames,
resources: keyResources
};
});
return (
<OrgProvider org={org}>
<VirtualApiKeysTable virtualApiKeys={rows} orgId={params.orgId} />
</OrgProvider>
);
}
@@ -1,37 +0,0 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { getTranslations } from "next-intl/server";
type VirtualApiKeysListLayoutProps = {
children: React.ReactNode;
params: Promise<{ orgId: string }>;
};
export default async function VirtualApiKeysListLayout({
children,
params
}: VirtualApiKeysListLayoutProps) {
const { orgId } = await params;
const t = await getTranslations();
const navItems = [
{
title: t("virtualApiKeysTabIdentity"),
href: `/${orgId}/settings/virtual-api-keys/identity`
},
{
title: t("virtualApiKeysTabVirtual"),
href: `/${orgId}/settings/virtual-api-keys/keys`
}
];
return (
<>
<SettingsSectionTitle
title={t("virtualApiKeysTitle")}
description={t("virtualApiKeysDescription")}
/>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</>
);
}
@@ -1,17 +1,156 @@
import type { Metadata } from "next";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { redirect } from "next/navigation";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import VirtualApiKeysBanner from "@app/components/VirtualApiKeysBanner";
import VirtualApiKeysTable, {
type VirtualApiKeyRow
} from "@app/components/VirtualApiKeysTable";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
import type { ListVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
import type { ListUsersResponse } from "@server/routers/user";
import type { ListResourcesResponse } from "@server/routers/resource";
export const metadata: Metadata = {
title: "Virtual API Keys"
};
type VirtualApiKeysIndexPageProps = {
type VirtualApiKeysPageProps = {
params: Promise<{ orgId: string }>;
};
export default async function VirtualApiKeysIndexPage(
props: VirtualApiKeysIndexPageProps
export const dynamic = "force-dynamic";
export default async function VirtualApiKeysPage(
props: VirtualApiKeysPageProps
) {
const params = await props.params;
redirect(`/${params.orgId}/settings/virtual-api-keys/identity`);
const cookieHeader = await authCookieHeader();
const t = await getTranslations();
let keys: ListVirtualApiKeysResponse["virtualApiKeys"] = [];
let users: {
userId: string;
email: string | null;
name: string | null;
username: string | null;
}[] = [];
let resources: {
resourceId: number;
name: string;
niceId: string;
}[] = [];
try {
const [keysRes, usersRes, resourcesRes] = await Promise.all([
internal.get<AxiosResponse<ListVirtualApiKeysResponse>>(
`/org/${params.orgId}/virtual-api-keys?page=1&pageSize=1000`,
cookieHeader
),
internal.get<AxiosResponse<ListUsersResponse>>(
`/org/${params.orgId}/users?page=1&pageSize=1000`,
cookieHeader
),
internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources?page=1&pageSize=1000`,
cookieHeader
)
]);
keys = keysRes.data.data.virtualApiKeys ?? [];
users = (usersRes.data.data.users ?? []).map((u) => ({
userId: u.id,
email: u.email ?? null,
name: u.name ?? null,
username: u.username ?? null
}));
resources = (resourcesRes.data.data.resources ?? []).map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId
}));
} catch {
// leave empty; page still renders
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
cookieHeader
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources`);
}
const userById = new Map(users.map((u) => [u.userId, u]));
const resourceById = new Map(resources.map((r) => [r.resourceId, r]));
const rows: VirtualApiKeyRow[] = keys.map((key) => {
const user = key.userId ? userById.get(key.userId) : undefined;
const keyResources = key.resourceIds
.map((id) => resourceById.get(id))
.filter(Boolean) as {
resourceId: number;
name: string;
niceId: string;
}[];
const resourceNames = key.allResources
? t("virtualApiKeysAllResources")
: keyResources.map((r) => r.name).join(", ") ||
t("virtualApiKeysNoResources");
return {
virtualApiKeyId: key.virtualApiKeyId,
orgId: key.orgId,
kind: key.kind,
userId: key.userId,
name: key.name,
description: key.description,
lastChars: key.lastChars,
allResources: key.allResources,
expiresAt: key.expiresAt,
lastUsedAt: key.lastUsedAt,
createdAt: key.createdAt,
createdByUserId: key.createdByUserId,
resourceIds: key.resourceIds,
userName: user?.name ?? null,
username: user?.username ?? null,
userEmail: user?.email ?? null,
resourceNames,
resources: keyResources
};
});
return (
<>
<SettingsSectionTitle
title={t("virtualApiKeysTitle")}
description={t("virtualApiKeysDescription")}
/>
<VirtualApiKeysBanner orgId={params.orgId} />
<OrgProvider org={org}>
<VirtualApiKeysTable
virtualApiKeys={rows}
orgId={params.orgId}
/>
</OrgProvider>
</>
);
}
+1 -1
View File
@@ -399,7 +399,7 @@ function AuthPageSettings({
</div>
)}
{build !== "oss" && (build === "enterprise" ||
{(build === "enterprise" ||
!isPaidUser(
tierMatrix.loginPageDomain
)) &&
+1 -1
View File
@@ -52,7 +52,7 @@ export default function CreateRoleForm({
requireDeviceApproval: values.requireDeviceApproval,
allowSsh: values.allowSsh
};
if (isPaidUser(tierMatrix.advancedPrivateResources)) {
if (isPaidUser(tierMatrix.roleBasedSSHControls)) {
payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands =
+2 -53
View File
@@ -54,8 +54,6 @@ import {
getBudgetRowsErrors,
type BudgetRow
} from "@app/components/BudgetsEditor";
import VirtualApiKeyEmailSection from "@app/components/VirtualApiKeyEmailSection";
import type { Tag } from "@app/components/tags/tag-input";
export type CreatedVirtualApiKey = {
virtualApiKeyId: string;
@@ -103,9 +101,6 @@ export default function CreateVirtualApiKeyForm({
>([]);
const [pendingBudgetRows, setPendingBudgetRows] = useState<BudgetRow[]>([]);
const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false);
const [sendEmail, setSendEmail] = useState(false);
const [sendToAttributedUser, setSendToAttributedUser] = useState(false);
const [emailTags, setEmailTags] = useState<Tag[]>([]);
const formSchema = z.object({
name: z.string().min(1),
@@ -128,9 +123,6 @@ export default function CreateVirtualApiKeyForm({
setSelectedResources([]);
setPendingBudgetRows([]);
setAttemptedBudgetsSave(false);
setSendEmail(false);
setSendToAttributedUser(false);
setEmailTags([]);
form.reset();
}
@@ -149,20 +141,6 @@ export default function CreateVirtualApiKeyForm({
return;
}
if (
env.email.emailEnabled &&
sendEmail &&
!sendToAttributedUser &&
emailTags.length === 0
) {
toast({
variant: "destructive",
title: t("virtualApiKeysEmailRecipientsRequired"),
description: t("virtualApiKeysEmailRecipientsRequired")
});
return;
}
return onSubmit(values);
}
@@ -179,16 +157,7 @@ export default function CreateVirtualApiKeyForm({
allResources,
resourceIds: allResources
? []
: selectedResources.map((r) => r.resourceId),
sendEmail: env.email.emailEnabled && sendEmail,
sendToAttributedUser:
env.email.emailEnabled &&
sendEmail &&
sendToAttributedUser,
emails:
env.email.emailEnabled && sendEmail
? emailTags.map((tag) => tag.text)
: []
: selectedResources.map((r) => r.resourceId)
}
)
.catch((e) => {
@@ -504,26 +473,6 @@ export default function CreateVirtualApiKeyForm({
</div>
)}
</div>
<VirtualApiKeyEmailSection
emailEnabled={
env.email.emailEnabled
}
mode="create"
sendEmail={sendEmail}
onSendEmailChange={setSendEmail}
sendToAttributedUser={
sendToAttributedUser
}
onSendToAttributedUserChange={
setSendToAttributedUser
}
hasAssociatedUser={
!!selectedUser
}
emailTags={emailTags}
onEmailTagsChange={setEmailTags}
/>
</div>
<div className="space-y-4 mt-4">
@@ -556,7 +505,7 @@ export default function CreateVirtualApiKeyForm({
</CredenzaClose>
<Button
type="button"
onClick={form.handleSubmit(handleFormSubmit)}
onClick={form.handleSubmit(onSubmit)}
loading={loading}
disabled={credential !== null || loading}
>
+123 -109
View File
@@ -38,11 +38,14 @@ import { useQuery } from "@tanstack/react-query";
import { AxiosResponse } from "axios";
import {
AlertCircle,
CheckIcon,
Building2,
Check,
CheckCircle2,
ChevronsUpDown,
ExternalLink,
Globe,
KeyRound
KeyRound,
Zap
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
@@ -606,72 +609,61 @@ export default function DomainPicker({
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"h-9 w-full justify-between font-normal",
!selectedBaseDomain &&
"text-muted-foreground"
)}
className="w-full justify-between"
>
<span className="truncate text-left">
{selectedBaseDomain
? selectedBaseDomain.domain
: t("domainPickerSelectBaseDomain")}
</span>
{selectedBaseDomain ? (
<div className="flex items-center gap-x-2 min-w-0 flex-1">
{selectedBaseDomain.type ===
"organization" ? null : (
<Zap className="h-4 w-4 shrink-0" />
)}
<span className="truncate">
{selectedBaseDomain.domain}
</span>
{selectedBaseDomain.verified &&
selectedBaseDomain.domainType !==
"wildcard" && (
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
)}
</div>
) : (
t("domainPickerSelectBaseDomain")
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<PopoverContent className="w-[400px] p-0" align="start">
<Command className="rounded-lg">
<CommandInput
placeholder={t("domainPickerSearchDomains")}
className="border-0 focus:ring-0"
/>
<CommandList>
<CommandEmpty>
<CommandEmpty className="py-6 text-center">
<div className="text-muted-foreground text-sm">
{t("domainPickerNoDomainsFound")}
</CommandEmpty>
{organizationDomains.length > 0 && (
</div>
</CommandEmpty>
{organizationDomains.length > 0 && (
<>
<CommandGroup
heading={t(
"domainPickerOrganizationDomains"
)}
className="py-2"
>
{organizationDomains.map(
(orgDomain) => {
const description =
orgDomain.type ===
"wildcard"
? t(
"domainPickerManual"
)
: `${orgDomain.type.toUpperCase()} · ${
orgDomain.verified
? t(
"domainPickerVerified"
)
: t(
"domainPickerUnverified"
)
}`;
const optionId = `org-${orgDomain.domainId}`;
return (
<CommandList>
{organizationDomains.map(
(orgDomain) => (
<CommandItem
key={optionId}
value={`${orgDomain.baseDomain} ${description}`}
disabled={
!orgDomain.verified
}
key={`org-${orgDomain.domainId}`}
onSelect={() =>
handleBaseDomainSelect(
{
id: optionId,
id: `org-${orgDomain.domainId}`,
domain: orgDomain.baseDomain,
type: "organization",
verified:
@@ -683,63 +675,80 @@ export default function DomainPicker({
}
)
}
className="mx-2 rounded-md"
disabled={
!orgDomain.verified
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4 shrink-0",
selectedBaseDomain?.id ===
optionId
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
<Building2 className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{
orgDomain.baseDomain
}
</span>
<span className="text-muted-foreground text-xs leading-snug">
{
description
}
<span className="text-xs text-muted-foreground">
{orgDomain.type ===
"wildcard" ? (
t(
"domainPickerManual"
)
) : (
<>
{orgDomain.type.toUpperCase()}{" "}
{" "}
{orgDomain.verified
? t(
"domainPickerVerified"
)
: t(
"domainPickerUnverified"
)}
</>
)}
</span>
</div>
<Check
className={cn(
"h-4 w-4 text-primary",
selectedBaseDomain?.id ===
`org-${orgDomain.domainId}`
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
);
}
)}
)
)}
</CommandList>
</CommandGroup>
)}
{organizationDomains.length > 0 &&
(build === "saas" ||
{(build === "saas" ||
build === "enterprise") &&
!hideFreeDomain && <CommandSeparator />}
{(build === "saas" ||
build === "enterprise") &&
!hideFreeDomain && (
<CommandGroup
heading={
build === "enterprise"
? t(
"domainPickerProvidedDomains"
)
: t(
"domainPickerFreeDomains"
)
}
>
!hideFreeDomain && (
<CommandSeparator className="my-2" />
)}
</>
)}
{(build === "saas" || build === "enterprise") &&
!hideFreeDomain && (
<CommandGroup
heading={
build === "enterprise"
? t(
"domainPickerProvidedDomains"
)
: t(
"domainPickerFreeDomains"
)
}
className="py-2"
>
<CommandList>
<CommandItem
value={`${
build === "enterprise"
? t(
"domainPickerProvidedDomain"
)
: t(
"domainPickerFreeProvidedDomain"
)
} ${t("domainPickerSearchForAvailableDomains")}`}
disabled={requiresPaywall}
key="provided-search"
onSelect={() =>
handleBaseDomainSelect({
id: "provided-search",
@@ -755,18 +764,14 @@ export default function DomainPicker({
type: "provided-search"
})
}
className="mx-2 rounded-md"
disabled={requiresPaywall}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4 shrink-0",
selectedBaseDomain?.id ===
"provided-search"
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Zap className="h-4 w-4 text-primary" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{build ===
"enterprise"
? t(
@@ -776,16 +781,25 @@ export default function DomainPicker({
"domainPickerFreeProvidedDomain"
)}
</span>
<span className="text-muted-foreground text-xs leading-snug">
<span className="text-xs text-muted-foreground">
{t(
"domainPickerSearchForAvailableDomains"
)}
</span>
</div>
<Check
className={cn(
"h-4 w-4 text-primary",
selectedBaseDomain?.id ===
"provided-search"
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</CommandList>
</CommandGroup>
)}
</Command>
</PopoverContent>
</Popover>
+2 -5
View File
@@ -59,7 +59,7 @@ export default function EditRoleForm({
payload.name = values.name;
payload.description = values.description || undefined;
}
if (isPaidUser(tierMatrix.advancedPrivateResources)) {
if (isPaidUser(tierMatrix.roleBasedSSHControls)) {
payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands =
@@ -107,10 +107,7 @@ export default function EditRoleForm({
toast({
variant: "destructive",
title: t("aiBudgetErrorSave"),
description: formatAxiosError(
e,
t("aiBudgetErrorSave")
)
description: formatAxiosError(e, t("aiBudgetErrorSave"))
});
}
}
+1 -50
View File
@@ -60,8 +60,6 @@ import {
} from "@app/components/BudgetsEditor";
import { aiBudgetQueries } from "@app/lib/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import VirtualApiKeyEmailSection from "@app/components/VirtualApiKeyEmailSection";
import type { Tag } from "@app/components/tags/tag-input";
type FormProps = {
open: boolean;
@@ -116,9 +114,6 @@ export default function EditVirtualApiKeyForm({
const [credentialLoading, setCredentialLoading] = useState(false);
const [pendingBudgetRows, setPendingBudgetRows] = useState<BudgetRow[]>([]);
const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false);
const [sendEmail, setSendEmail] = useState(false);
const [sendToAttributedUser, setSendToAttributedUser] = useState(false);
const [emailTags, setEmailTags] = useState<Tag[]>([]);
const budgetScope = {
type: "virtualApiKey" as const,
@@ -161,9 +156,6 @@ export default function EditVirtualApiKeyForm({
setSelectedResources(
virtualApiKey.allResources ? [] : resourcesFromRow(virtualApiKey)
);
setSendEmail(false);
setSendToAttributedUser(false);
setEmailTags([]);
form.reset({
allResources: virtualApiKey.allResources
});
@@ -244,20 +236,6 @@ export default function EditVirtualApiKeyForm({
return;
}
if (
env.email.emailEnabled &&
sendEmail &&
!sendToAttributedUser &&
emailTags.length === 0
) {
toast({
variant: "destructive",
title: t("virtualApiKeysEmailRecipientsRequired"),
description: t("virtualApiKeysEmailRecipientsRequired")
});
return;
}
return onSubmit(values);
}
@@ -276,16 +254,7 @@ export default function EditVirtualApiKeyForm({
allResources: values.allResources,
resourceIds: values.allResources
? []
: selectedResources.map((r) => r.resourceId),
sendEmail: env.email.emailEnabled && sendEmail,
sendToAttributedUser:
env.email.emailEnabled &&
sendEmail &&
sendToAttributedUser,
emails:
env.email.emailEnabled && sendEmail
? emailTags.map((tag) => tag.text)
: []
: selectedResources.map((r) => r.resourceId)
}
)
.catch((e) => {
@@ -552,24 +521,6 @@ export default function EditVirtualApiKeyForm({
</div>
)}
</div>
<VirtualApiKeyEmailSection
emailEnabled={
env.email.emailEnabled
}
mode="edit"
sendEmail={sendEmail}
onSendEmailChange={setSendEmail}
sendToAttributedUser={
sendToAttributedUser
}
onSendToAttributedUserChange={
setSendToAttributedUser
}
hasAssociatedUser={!!selectedUser}
emailTags={emailTags}
onEmailTagsChange={setEmailTags}
/>
</div>
<div className="space-y-4 mt-4">
-192
View File
@@ -1,192 +0,0 @@
"use client";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Label } from "@app/components/ui/label";
import {
RolesSelector,
type SelectedRole
} from "@app/components/roles-selector";
import {
UsersSelector,
type SelectedUser
} from "@app/components/users-selector";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { EmailIdentityKeysResponse } from "@server/routers/virtualApiKey/types";
import { AxiosResponse } from "axios";
import { useState } from "react";
import { useTranslations } from "next-intl";
type EmailIdentityKeysFormProps = {
orgId: string;
open: boolean;
setOpen: (open: boolean) => void;
};
export default function EmailIdentityKeysForm({
orgId,
open,
setOpen
}: EmailIdentityKeysFormProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [sendToAll, setSendToAll] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const [selectedRoles, setSelectedRoles] = useState<SelectedRole[]>([]);
const [loading, setLoading] = useState(false);
function resetState() {
setSendToAll(false);
setSelectedUsers([]);
setSelectedRoles([]);
setLoading(false);
}
async function onSubmit() {
if (
!sendToAll &&
selectedUsers.length === 0 &&
selectedRoles.length === 0
) {
toast({
variant: "destructive",
title: t("virtualApiKeysEmailIdentityRecipientsRequired"),
description: t("virtualApiKeysEmailIdentityRecipientsRequired")
});
return;
}
setLoading(true);
try {
const res = await api.post<
AxiosResponse<EmailIdentityKeysResponse>
>(`/org/${orgId}/virtual-api-keys/email-identity-keys`, {
sendToAll,
userIds: sendToAll ? [] : selectedUsers.map((user) => user.id),
roleIds: sendToAll
? []
: selectedRoles.map((role) => Number(role.id))
});
const { sent, skipped } = res.data.data;
toast({
title: t("virtualApiKeysEmailIdentitySuccess"),
description:
skipped > 0
? `${t("virtualApiKeysEmailIdentitySuccessDescription", { sent })} ${t("virtualApiKeysEmailIdentitySkipped", { skipped })}`
: t("virtualApiKeysEmailIdentitySuccessDescription", {
sent
})
});
setOpen(false);
resetState();
} catch (e) {
toast({
variant: "destructive",
title: t("virtualApiKeysEmailIdentityError"),
description: formatAxiosError(
e,
t("virtualApiKeysEmailIdentityErrorDescription")
)
});
}
setLoading(false);
}
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
resetState();
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("virtualApiKeysEmailIdentity")}
</CredenzaTitle>
<CredenzaDescription>
{t("virtualApiKeysEmailIdentityDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
<div className="flex items-start space-x-2">
<Checkbox
id="email-identity-send-all"
checked={sendToAll}
onCheckedChange={(val) =>
setSendToAll(val === true)
}
className="mt-0.5"
/>
<div className="space-y-1">
<label
htmlFor="email-identity-send-all"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("virtualApiKeysEmailIdentitySendAll")}
</label>
<p className="text-sm text-muted-foreground">
{t(
"virtualApiKeysEmailIdentitySendAllDescription"
)}
</p>
</div>
</div>
<div className="space-y-2">
<Label>
{t("virtualApiKeysEmailIdentitySelectUsers")}
</Label>
<UsersSelector
orgId={orgId}
selectedUsers={selectedUsers}
onSelectUsers={setSelectedUsers}
disabled={sendToAll}
/>
</div>
<div className="space-y-2">
<Label>
{t("virtualApiKeysEmailIdentitySelectRoles")}
</Label>
<RolesSelector
orgId={orgId}
selectedRoles={selectedRoles}
onSelectRoles={setSelectedRoles}
disabled={sendToAll}
/>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="button"
onClick={onSubmit}
loading={loading}
disabled={loading}
>
{t("virtualApiKeysEmailIdentitySubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
-117
View File
@@ -1,117 +0,0 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionFooter
} from "@app/components/Settings";
import EmailIdentityKeysForm from "@app/components/EmailIdentityKeysForm";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
import { ArrowRight, ExternalLink, Globe, KeyRound, Mail } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useState } from "react";
const EXAMPLE_IDENTITY_KEY = formatVirtualApiKeyCredential(
"k7m2n9qx",
"a8f3c1e0b5d24791"
);
type IdentityKeysSplashProps = {
orgId: string;
};
export default function IdentityKeysSplash({ orgId }: IdentityKeysSplashProps) {
const t = useTranslations();
const { env } = useEnvContext();
const [emailOpen, setEmailOpen] = useState(false);
const emailEnabled = env.email.emailEnabled;
const dashboardUrl = env.app.dashboardUrl?.replace(/\/$/, "") ?? "";
const keysPath = `/${orgId}/keys`;
const keysUrl = dashboardUrl ? `${dashboardUrl}${keysPath}` : keysPath;
return (
<>
<SettingsSection>
<SettingsSectionBody>
<div className="flex flex-col items-center text-center py-6 md:py-10 px-2">
<KeyRound className="h-8 w-8 text-primary" />
<h2 className="mt-4 text-2xl font-semibold tracking-tight max-w-xl">
{t("virtualApiKeysIdentitySplashTitle")}
</h2>
<p className="mt-3 text-sm text-muted-foreground max-w-lg">
{t("virtualApiKeysIdentitySplashDescription")}
</p>
<div className="mt-8 w-full max-w-lg text-left space-y-3">
<p className="text-sm font-medium text-center">
{t("virtualApiKeysIdentitySplashRetrieveTitle")}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-start gap-2">
<Globe className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span>
{t(
"virtualApiKeysIdentitySplashRetrieveResource"
)}
</span>
</li>
<li className="flex items-start gap-2">
<ExternalLink className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span>
{t.rich(
"virtualApiKeysIdentitySplashRetrievePage",
{
url: () => (
<Link
href={keysPath}
className="font-medium text-foreground underline underline-offset-4 break-all"
>
{keysUrl}
</Link>
)
}
)}
</span>
</li>
</ul>
</div>
<p className="mt-8 text-sm text-muted-foreground max-w-lg">
{t("virtualApiKeysIdentitySplashManual")}
</p>
{!emailEnabled && (
<p className="mt-3 text-sm text-muted-foreground max-w-lg">
{t(
"virtualApiKeysEmailSmtpRequiredDescription"
)}
</p>
)}
</div>
</SettingsSectionBody>
<SettingsSectionFooter className="justify-center md:justify-center">
<Button
disabled={!emailEnabled}
onClick={() => setEmailOpen(true)}
>
{t("virtualApiKeysEmailIdentity")}
</Button>
<Button asChild variant="outline">
<Link href={`/${orgId}/settings/virtual-api-keys/keys`}>
{t("virtualApiKeysIdentitySplashGoToVirtual")}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</SettingsSectionFooter>
</SettingsSection>
<EmailIdentityKeysForm
orgId={orgId}
open={emailOpen}
setOpen={setEmailOpen}
/>
</>
);
}
+1 -12
View File
@@ -1,7 +1,6 @@
"use client";
import DomainPicker from "@app/components/DomainPicker";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SettingsFormCell,
SettingsFormGrid,
@@ -25,7 +24,6 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useTranslations } from "next-intl";
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
@@ -49,8 +47,7 @@ export function PrivateResourceHttpFields({
disabled = false,
siteResourceId,
labelPrefix = "edit",
hideDomainPicker = false,
hidePaidFeaturesAlert = false
hideDomainPicker = false
}: PrivateResourceHttpFieldsProps) {
const t = useTranslations();
const schemeLabelKey =
@@ -88,14 +85,6 @@ export function PrivateResourceHttpFields({
return (
<SettingsFormGrid>
{!hidePaidFeaturesAlert && (
<SettingsFormCell span="full">
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="quarter">
<FormField
control={control}
+1 -3
View File
@@ -18,7 +18,6 @@ import {
type LauncherAccessFields
} from "@app/lib/launcherResourceAccess";
import type { PrivateResourceMode } from "@app/lib/privateResourceForm";
import { build } from "@server/build";
import { useTranslations } from "next-intl";
type SiteResourceInfoInput = {
@@ -121,8 +120,7 @@ export function PrivateResourceInfoSections({
(siteResource.mode === "http" || siteResource.mode === "inference") &&
siteResource.ssl &&
siteResource.domainId &&
siteResource.fullDomain &&
build != "oss"
siteResource.fullDomain
);
const showPortRestrictions =
isPanel &&
@@ -38,7 +38,6 @@ type PrivateResourceSshFieldsProps = {
labelPrefix?: "create" | "edit";
showSshSettings?: boolean;
layout?: "default" | "wizard";
showPaidFeaturesAlert?: boolean;
hideAlias?: boolean;
embedInParentGrid?: boolean;
isNativeSsh?: boolean;
@@ -55,7 +54,6 @@ export function PrivateResourceSshFields({
labelPrefix = "edit",
showSshSettings = true,
layout = "default",
showPaidFeaturesAlert = true,
hideAlias = false,
embedInParentGrid = false,
isNativeSsh: isNativeSshProp
@@ -313,13 +311,6 @@ export function PrivateResourceSshFields({
const content: ReactNode = (
<>
{showPaidFeaturesAlert && layout === "default" && (
<SettingsFormCell span="full">
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
</SettingsFormCell>
)}
{sshSettingsFields}
{destinationSection}
</>
-1
View File
@@ -429,7 +429,6 @@ export default function PrivateResourcesTable({
const fullDomain = resourceRow.fullDomain;
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
const did =
build !== "oss" &&
resourceRow.ssl &&
domainId != null &&
domainId !== "" &&
-1
View File
@@ -468,7 +468,6 @@ export default function PublicResourcesTable({
const domainId = resourceRow.domainId;
const certHostname = resourceRow.fullDomain;
const showHttpsCertIndicator =
build !== "oss" &&
resourceRow.ssl &&
certHostname != null &&
certHostname !== "";
+1 -2
View File
@@ -40,8 +40,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const showCertificate = !!(
isDomainResource &&
resource.domainId &&
resource.fullDomain &&
build != "oss"
resource.fullDomain
);
const showType = !!(isDomainResource && resource.mode);
const showAuth = resource.mode !== "inference";
+185 -183
View File
@@ -212,7 +212,7 @@ export function RoleForm({
}
}, [variant, role, form]);
const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const sshDisabled = !isPaidUser(tierMatrix.roleBasedSSHControls);
const sshSudoMode = form.watch("sshSudoMode");
const isAdminRole = variant === "edit" && role?.isAdmin === true;
const [pendingImport, setPendingImport] =
@@ -235,12 +235,6 @@ export function RoleForm({
setAttemptedBudgetsSave(false);
}, [variant, budgetsQuery.data]);
useEffect(() => {
if (sshDisabled) {
form.setValue("allowSsh", false);
}
}, [sshDisabled, form]);
async function handleFileDrop(
file: File,
field: RoleTextImportField
@@ -487,115 +481,161 @@ export function RoleForm({
/>
</div>
{/* SSH tab - hidden when enterprise features are disabled */}
{!env.flags.disableEnterpriseFeatures && (
<div className="space-y-4 mt-4">
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<FormField
control={form.control}
name="allowSsh"
render={({ field }) => {
const allowSshOptions: OptionSelectOption<
"allow" | "disallow"
>[] = [
{
value: "allow",
label: t("roleAllowSshAllow")
},
{
value: "disallow",
label: t("roleAllowSshDisallow")
}
];
return (
<FormItem>
<FormLabel>
{t("roleAllowSsh")}
</FormLabel>
<OptionSelect<
"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"
)
<div className="space-y-4 mt-4">
<FormField
control={form.control}
name="allowSsh"
render={({ field }) => {
const allowSshOptions: OptionSelectOption<
"allow" | "disallow"
>[] = [
{
value: "allow",
label: t("roleAllowSshAllow")
},
{
value: "disallow",
label: t("roleAllowSshDisallow")
}
];
return (
<FormItem>
<FormLabel>
{t("roleAllowSsh")}
</FormLabel>
<OptionSelect<"allow" | "disallow">
options={allowSshOptions}
value={
sshDisabled
? "disallow"
: field.value
? "allow"
: "disallow"
}
];
return (
<FormItem>
<FormLabel>
{t("sshSudoMode")}
</FormLabel>
<OptionSelect<SshSudoMode>
options={sudoOptions}
value={field.value}
onChange={field.onChange}
cols={3}
disabled={sshDisabled}
/>
<FormMessage />
</FormItem>
);
}}
/>
{sshSudoMode === "commands" && (
onChange={(v) => {
if (sshDisabled) return;
field.onChange(
v === "allow"
);
}}
cols={2}
disabled={sshDisabled}
/>
<FormDescription>
{t("roleAllowSshDescription")}
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
{/* SSH tab - hidden when enterprise features are disabled */}
{!env.flags.disableEnterpriseFeatures && (
<>
<PaidFeaturesAlert
tiers={tierMatrix.roleBasedSSHControls}
/>
<FormField
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 }) => (
<FormItem>
<FormLabel>
{t("sshSudoCommands")}
{t("sshUnixGroups")}
</FormLabel>
<FormControl>
<Textarea
{...field}
{...getTextImportDropHandlers(
"sshSudoCommands"
"sshUnixGroups"
)}
placeholder={
sshDisabled
@@ -608,97 +648,59 @@ export function RoleForm({
className={cn(
"h-20 min-h-20",
dragOverField ===
"sshSudoCommands" &&
"sshUnixGroups" &&
"border-primary"
)}
/>
</FormControl>
<FormDescription>
{t(
"sshSudoCommandsDescription"
"sshUnixGroupsDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="sshUnixGroups"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshUnixGroups")}
</FormLabel>
<FormControl>
<Textarea
{...field}
{...getTextImportDropHandlers(
"sshUnixGroups"
)}
placeholder={
sshDisabled
? undefined
: t(
"roleTextFieldPlaceholder"
)
}
disabled={sshDisabled}
className={cn(
"h-20 min-h-20",
dragOverField ===
"sshUnixGroups" &&
"border-primary"
)}
/>
</FormControl>
<FormDescription>
{t("sshUnixGroupsDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<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>
)}
<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">
<p className="text-sm text-muted-foreground">
@@ -1,151 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Checkbox } from "@app/components/ui/checkbox";
import { FormLabel } from "@app/components/ui/form";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { useTranslations } from "next-intl";
type VirtualApiKeyEmailSectionProps = {
emailEnabled: boolean;
mode: "create" | "edit";
sendEmail: boolean;
onSendEmailChange: (value: boolean) => void;
sendToAttributedUser: boolean;
onSendToAttributedUserChange: (value: boolean) => void;
hasAssociatedUser: boolean;
emailTags: Tag[];
onEmailTagsChange: (tags: Tag[]) => void;
};
export default function VirtualApiKeyEmailSection({
emailEnabled,
mode,
sendEmail,
onSendEmailChange,
sendToAttributedUser,
onSendToAttributedUserChange,
hasAssociatedUser,
emailTags,
onEmailTagsChange
}: VirtualApiKeyEmailSectionProps) {
const t = useTranslations();
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
number | null
>(null);
useEffect(() => {
if (!hasAssociatedUser && sendToAttributedUser) {
onSendToAttributedUserChange(false);
}
}, [hasAssociatedUser, sendToAttributedUser, onSendToAttributedUserChange]);
const checkboxId =
mode === "create"
? "virtual-api-key-send-email"
: "edit-virtual-api-key-send-email";
const sendToUserId =
mode === "create"
? "virtual-api-key-send-to-user"
: "edit-virtual-api-key-send-to-user";
return (
<div className="space-y-3">
<div className="flex items-start space-x-2">
<Checkbox
id={checkboxId}
checked={emailEnabled ? sendEmail : false}
disabled={!emailEnabled}
onCheckedChange={(val) => {
if (emailEnabled) {
onSendEmailChange(val === true);
}
}}
className="mt-0.5"
/>
<div className="space-y-1">
<label
htmlFor={checkboxId}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t(
mode === "create"
? "virtualApiKeysEmailOnGenerate"
: "virtualApiKeysEmailThisKey"
)}
</label>
<p className="text-sm text-muted-foreground">
{emailEnabled
? t(
mode === "create"
? "virtualApiKeysEmailOnGenerateDescription"
: "virtualApiKeysEmailThisKeyDescription"
)
: t("virtualApiKeysEmailSmtpRequiredDescription")}
</p>
</div>
</div>
{emailEnabled && sendEmail && (
<div className="space-y-4 pl-6">
<div className="flex items-start space-x-2">
<Checkbox
id={sendToUserId}
checked={sendToAttributedUser}
disabled={!hasAssociatedUser}
onCheckedChange={(val) =>
onSendToAttributedUserChange(val === true)
}
className="mt-0.5"
/>
<div className="space-y-1">
<label
htmlFor={sendToUserId}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("virtualApiKeysEmailSendToUser")}
</label>
<p className="text-sm text-muted-foreground">
{hasAssociatedUser
? t(
"virtualApiKeysEmailSendToUserDescription"
)
: t(
"virtualApiKeysEmailSendToUserDisabled"
)}
</p>
</div>
</div>
<div className="space-y-2">
<FormLabel>
{t("virtualApiKeysEmailAdditional")}
</FormLabel>
<TagInput
activeTagIndex={activeEmailTagIndex}
setActiveTagIndex={setActiveEmailTagIndex}
placeholder={t(
"virtualApiKeysEmailAdditionalPlaceholder"
)}
size="sm"
tags={emailTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(emailTags)
: newTags;
onEmailTagsChange(next as Tag[]);
}}
allowDuplicates={false}
sortTags
validateTag={(tag) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag)
}
delimiterList={[",", "Enter"]}
/>
</div>
</div>
)}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { ArrowRight, KeyRound } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import DismissableBanner from "./DismissableBanner";
type VirtualApiKeysBannerProps = {
orgId: string;
};
export const VirtualApiKeysBanner = ({ orgId }: VirtualApiKeysBannerProps) => {
const t = useTranslations();
const { env } = useEnvContext();
const dashboardUrl = env.app.dashboardUrl?.replace(/\/$/, "") ?? "";
const keysUrl = dashboardUrl
? `${dashboardUrl}/${orgId}/keys`
: `/${orgId}/keys`;
return (
<DismissableBanner
storageKey="virtual-api-keys-banner-dismissed"
version={1}
title={t("virtualApiKeysBannerTitle")}
titleIcon={<KeyRound className="w-5 h-5 text-primary" />}
description={t("virtualApiKeysBannerDescription", { keysUrl })}
>
<Link href={`/${orgId}/keys`}>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("virtualApiKeysBannerButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
);
};
export default VirtualApiKeysBanner;
-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(
name: string,
value: string,