Placeholder screen and certs are working

This commit is contained in:
Owen
2026-04-12 16:49:49 -07:00
parent 789b991c56
commit 89b6b1fb56
13 changed files with 127 additions and 146 deletions

View File

@@ -19,7 +19,8 @@ export enum TierFeature {
SshPam = "sshPam",
FullRbac = "fullRbac",
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
SIEM = "siem" // handle downgrade by disabling SIEM integrations
SIEM = "siem", // handle downgrade by disabling SIEM integrations
HTTPPrivateResources = "httpPrivateResources" // handle downgrade by disabling HTTP private resources
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -56,5 +57,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
[TierFeature.SIEM]: ["enterprise"]
[TierFeature.SIEM]: ["enterprise"],
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"]
};

View File

@@ -1,39 +0,0 @@
import crypto from "crypto";
export function encryptData(data: string, key: Buffer): string {
const algorithm = "aes-256-gcm";
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(data, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
// Combine IV, auth tag, and encrypted data
return iv.toString("hex") + ":" + authTag.toString("hex") + ":" + encrypted;
}
// Helper function to decrypt data (you'll need this to read certificates)
export function decryptData(encryptedData: string, key: Buffer): string {
const algorithm = "aes-256-gcm";
const parts = encryptedData.split(":");
if (parts.length !== 3) {
throw new Error("Invalid encrypted data format");
}
const iv = Buffer.from(parts[0], "hex");
const authTag = Buffer.from(parts[1], "hex");
const encrypted = parts[2];
const decipher = crypto.createDecipheriv(algorithm, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
// openssl rand -hex 32 > config/encryption.key

View File

@@ -5,7 +5,7 @@ import config from "@server/lib/config";
import z from "zod";
import logger from "@server/logger";
import semver from "semver";
import { getValidCertificatesForDomains } from "#private/lib/certificates";
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
interface IPRange {
start: bigint;

View File

@@ -15,9 +15,10 @@ import fs from "fs";
import crypto from "crypto";
import { certificates, domains, db } from "@server/db";
import { and, eq } from "drizzle-orm";
import { encryptData, decryptData } from "@server/lib/encryption";
import { encrypt, decrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import config from "#private/lib/config";
import privateConfig from "#private/lib/config";
import config from "@server/lib/config";
interface AcmeCert {
domain: { main: string; sans?: string[] };
@@ -32,14 +33,6 @@ interface AcmeJson {
};
}
function getEncryptionKey(): Buffer {
const keyHex = config.getRawPrivateConfig().server.encryption_key;
if (!keyHex) {
throw new Error("acmeCertSync: encryption key is not configured");
}
return Buffer.from(keyHex, "hex");
}
async function findDomainId(certDomain: string): Promise<string | null> {
// Strip wildcard prefix before lookup (*.example.com -> example.com)
const lookupDomain = certDomain.startsWith("*.")
@@ -99,9 +92,7 @@ async function syncAcmeCerts(
try {
raw = fs.readFileSync(acmeJsonPath, "utf8");
} catch (err) {
logger.debug(
`acmeCertSync: could not read ${acmeJsonPath}: ${err}`
);
logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`);
return;
}
@@ -121,15 +112,13 @@ async function syncAcmeCerts(
return;
}
const encryptionKey = getEncryptionKey();
for (const cert of resolverData.Certificates) {
const domain = cert.domain?.main;
if (!domain) {
logger.debug(
`acmeCertSync: skipping cert with missing domain`
);
logger.debug(`acmeCertSync: skipping cert with missing domain`);
continue;
}
@@ -161,9 +150,9 @@ async function syncAcmeCerts(
if (existing.length > 0 && existing[0].certFile) {
try {
const storedCertPem = decryptData(
const storedCertPem = decrypt(
existing[0].certFile,
encryptionKey
config.getRawConfig().server.secret!
);
if (storedCertPem === certPem) {
logger.debug(
@@ -185,9 +174,7 @@ async function syncAcmeCerts(
if (firstCertPem) {
try {
const x509 = new crypto.X509Certificate(firstCertPem);
expiresAt = Math.floor(
new Date(x509.validTo).getTime() / 1000
);
expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000);
} catch (err) {
logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
@@ -196,8 +183,8 @@ async function syncAcmeCerts(
}
const wildcard = domain.startsWith("*.");
const encryptedCert = encryptData(certPem, encryptionKey);
const encryptedKey = encryptData(keyPem, encryptionKey);
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);
@@ -249,16 +236,16 @@ async function syncAcmeCerts(
}
export function initAcmeCertSync(): void {
const privateConfig = config.getRawPrivateConfig();
const privateConfigData = privateConfig.getRawPrivateConfig();
if (!privateConfig.flags?.enable_acme_cert_sync) {
if (!privateConfigData.flags?.enable_acme_cert_sync) {
return;
}
const acmeJsonPath =
privateConfig.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
const resolver = privateConfig.acme?.resolver ?? "letsencrypt";
const intervalMs = privateConfig.acme?.sync_interval_ms ?? 5000;
privateConfigData.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
logger.info(
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
@@ -274,4 +261,4 @@ export function initAcmeCertSync(): void {
logger.error(`acmeCertSync: error during sync: ${err}`);
});
}, intervalMs);
}
}

View File

@@ -11,23 +11,15 @@
* This file is not licensed under the AGPLv3.
*/
import config from "./config";
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 { decryptData } from "@server/lib/encryption";
import { decrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import cache from "#private/lib/cache";
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key;
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}
// Define the return type for clarity and type safety
export type CertificateResult = {
@@ -45,7 +37,7 @@ export async function getValidCertificatesForDomains(
domains: Set<string>,
useCache: boolean = true
): Promise<Array<CertificateResult>> {
loadEncryptData(); // Ensure encryption key is loaded
const finalResults: CertificateResult[] = [];
const domainsToQuery = new Set<string>();
@@ -68,7 +60,7 @@ export async function getValidCertificatesForDomains(
// 2. If all domains were resolved from the cache, return early
if (domainsToQuery.size === 0) {
const decryptedResults = decryptFinalResults(finalResults);
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
return decryptedResults;
}
@@ -173,22 +165,23 @@ export async function getValidCertificatesForDomains(
}
}
const decryptedResults = decryptFinalResults(finalResults);
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
return decryptedResults;
}
function decryptFinalResults(
finalResults: CertificateResult[]
finalResults: CertificateResult[],
secret: string
): CertificateResult[] {
const validCertsDecrypted = finalResults.map((cert) => {
// Decrypt and save certificate file
const decryptedCert = decryptData(
const decryptedCert = decrypt(
cert.certFile!, // is not null from query
encryptionKey
secret
);
// Decrypt and save key file
const decryptedKey = decryptData(cert.keyFile!, encryptionKey);
const decryptedKey = decrypt(cert.keyFile!, secret);
// Return only the certificate data without org information
return {

View File

@@ -34,10 +34,6 @@ export const privateConfigSchema = z.object({
}),
server: z
.object({
encryption_key: z
.string()
.optional()
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
reo_client_id: z
.string()
.optional()
@@ -96,7 +92,7 @@ export const privateConfigSchema = z.object({
enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional(),
enable_acme_cert_sync: z.boolean().optional().default(false)
enable_acme_cert_sync: z.boolean().optional().default(true)
})
.optional()
.prefault({}),

View File

@@ -268,10 +268,10 @@ export async function getTraefikConfig(
});
// Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge
const siteResourcesWithAliases = await db
const siteResourcesWithFullDomain = await db
.select({
siteResourceId: siteResources.siteResourceId,
alias: siteResources.alias,
fullDomain: siteResources.fullDomain,
mode: siteResources.mode
})
.from(siteResources)
@@ -279,7 +279,7 @@ export async function getTraefikConfig(
.where(
and(
eq(siteResources.enabled, true),
isNotNull(siteResources.alias),
isNotNull(siteResources.fullDomain),
eq(siteResources.mode, "http"),
eq(siteResources.ssl, true),
or(
@@ -305,9 +305,9 @@ export async function getTraefikConfig(
}
}
// Include siteResource aliases so pangolin-dns also fetches certs for them
for (const sr of siteResourcesWithAliases) {
if (sr.alias) {
domains.add(sr.alias);
for (const sr of siteResourcesWithFullDomain) {
if (sr.fullDomain) {
domains.add(sr.fullDomain);
}
}
// get the valid certs for these domains
@@ -904,7 +904,7 @@ export async function getTraefikConfig(
// 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 (siteResourcesWithAliases.length > 0) {
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()) {
@@ -913,13 +913,13 @@ export async function getTraefikConfig(
}
}
for (const sr of siteResourcesWithAliases) {
if (!sr.alias) continue;
for (const sr of siteResourcesWithFullDomain) {
if (!sr.fullDomain) continue;
// Skip if this alias is already handled by a resource router
if (existingFullDomains.has(sr.alias)) continue;
if (existingFullDomains.has(sr.fullDomain)) continue;
const alias = sr.alias;
const fullDomain = sr.fullDomain;
const srKey = `site-resource-cert-${sr.siteResourceId}`;
const siteResourceServiceName = `${srKey}-service`;
const siteResourceRouterName = `${srKey}-router`;
@@ -970,7 +970,7 @@ export async function getTraefikConfig(
],
middlewares: [redirectHttpsMiddlewareName],
service: siteResourceServiceName,
rule: `Host(\`${alias}\`)`,
rule: `Host(\`${fullDomain}\`)`,
priority: 100
};
@@ -979,7 +979,7 @@ export async function getTraefikConfig(
if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
) {
const domainParts = alias.split(".");
const domainParts = fullDomain.split(".");
const wildCard =
domainParts.length <= 2
? `*.${domainParts.join(".")}`
@@ -999,11 +999,11 @@ export async function getTraefikConfig(
} else {
// pangolin-dns: only add route if we already have a valid cert
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === alias
(cert) => cert.queriedDomain === fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for siteResource alias: ${alias}`
`No matching certificate found for siteResource alias: ${fullDomain}`
);
continue;
}
@@ -1016,10 +1016,21 @@ export async function getTraefikConfig(
],
service: siteResourceServiceName,
middlewares: [siteResourceRewriteMiddlewareName],
rule: `Host(\`${alias}\`)`,
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*\`))`,
priority: 101,
tls
};
}
}

View File

@@ -24,14 +24,8 @@ import {
User,
certificates,
exitNodeOrgs,
RemoteExitNode,
olms,
newts,
clients,
sites,
domains,
orgDomains,
targets,
loginPage,
loginPageOrg,
LoginPage,
@@ -70,12 +64,9 @@ import {
updateAndGenerateEndpointDestinations,
updateSiteBandwidth
} from "@server/routers/gerbil";
import * as gerbil from "@server/routers/gerbil";
import logger from "@server/logger";
import { decryptData } from "@server/lib/encryption";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import privateConfig from "#private/lib/config";
import * as fs from "fs";
import { exchangeSession } from "@server/routers/badger";
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
@@ -298,25 +289,11 @@ hybridRouter.get(
}
);
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
encryptionKeyHex =
privateConfig.getRawPrivateConfig().server.encryption_key;
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}
// Get valid certificates for given domains (supports wildcard certs)
hybridRouter.get(
"/certificates/domains",
async (req: Request, res: Response, next: NextFunction) => {
try {
loadEncryptData(); // Ensure encryption key is loaded
const parsed = getCertificatesByDomainsQuerySchema.safeParse(
req.query
);
@@ -447,13 +424,13 @@ hybridRouter.get(
const result = filtered.map((cert) => {
// Decrypt and save certificate file
const decryptedCert = decryptData(
const decryptedCert = decrypt(
cert.certFile!, // is not null from query
encryptionKey
config.getRawConfig().server.secret!
);
// Decrypt and save key file
const decryptedKey = decryptData(cert.keyFile!, encryptionKey);
const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!);
// Return only the certificate data without org information
return {
@@ -833,9 +810,12 @@ hybridRouter.get(
)
);
logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows);
logger.debug(
`User ${userId} has roles in org ${orgId}:`,
userOrgRoleRows
);
return response<{ roleId: number, roleName: string }[]>(res, {
return response<{ roleId: number; roleName: string }[]>(res, {
data: userOrgRoleRows,
success: true,
error: false,

View File

@@ -17,7 +17,7 @@ import {
portRangeStringSchema
} from "@server/lib/ip";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response";
import logger from "@server/logger";
@@ -201,6 +201,21 @@ export async function createSiteResource(
subdomain
} = parsedBody.data;
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix[TierFeature.HTTPPrivateResources]
);
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 [site] = await db
.select()

View File

@@ -1,4 +1,3 @@
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import {
clientSiteResources,
clientSiteResourcesAssociationsCache,
@@ -13,7 +12,8 @@ import {
Transaction,
userSiteResources
} from "@server/db";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import {
generateAliasConfig,
@@ -237,6 +237,21 @@ export async function updateSiteResource(
);
}
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
existingSiteResource.orgId,
tierMatrix[TierFeature.HTTPPrivateResources]
);
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.sshPam

View File

@@ -45,6 +45,7 @@ export default function CreateInternalResourceDialog({
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, setIsSubmitting] = useState(false);
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
async function handleSubmit(values: InternalResourceFormValues) {
setIsSubmitting(true);
@@ -159,6 +160,7 @@ export default function CreateInternalResourceDialog({
orgId={orgId}
formId="create-internal-resource-form"
onSubmit={handleSubmit}
onSubmitDisabledChange={setIsHttpModeDisabled}
/>
</CredenzaBody>
<CredenzaFooter>
@@ -174,7 +176,7 @@ export default function CreateInternalResourceDialog({
<Button
type="submit"
form="create-internal-resource-form"
disabled={isSubmitting}
disabled={isSubmitting || isHttpModeDisabled}
loading={isSubmitting}
>
{t("createInternalResourceDialogCreateResource")}

View File

@@ -50,6 +50,7 @@ export default function EditInternalResourceDialog({
const api = createApiClient(useEnvContext());
const queryClient = useQueryClient();
const [isSubmitting, startTransition] = useTransition();
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
async function handleSubmit(values: InternalResourceFormValues) {
try {
@@ -177,6 +178,7 @@ export default function EditInternalResourceDialog({
onSubmit={(values) =>
startTransition(() => handleSubmit(values))
}
onSubmitDisabledChange={setIsHttpModeDisabled}
/>
</CredenzaBody>
<CredenzaFooter>
@@ -192,7 +194,7 @@ export default function EditInternalResourceDialog({
<Button
type="submit"
form="edit-internal-resource-form"
disabled={isSubmitting}
disabled={isSubmitting || isHttpModeDisabled}
loading={isSubmitting}
>
{t("editInternalResourceDialogSaveResource")}

View File

@@ -185,6 +185,7 @@ type InternalResourceFormProps = {
siteResourceId?: number;
formId: string;
onSubmit: (values: InternalResourceFormValues) => void | Promise<void>;
onSubmitDisabledChange?: (disabled: boolean) => void;
};
export function InternalResourceForm({
@@ -195,13 +196,15 @@ export function InternalResourceForm({
orgId,
siteResourceId,
formId,
onSubmit
onSubmit,
onSubmitDisabledChange
}: InternalResourceFormProps) {
const t = useTranslations();
const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam);
const httpSectionDisabled = !isPaidUser(tierMatrix.httpPrivateResources);
const nameRequiredKey =
variant === "create"
@@ -647,6 +650,10 @@ export function InternalResourceForm({
form
]);
useEffect(() => {
onSubmitDisabledChange?.(isHttpMode && httpSectionDisabled);
}, [isHttpMode, httpSectionDisabled, onSubmitDisabledChange]);
return (
<Form {...form}>
<form
@@ -853,6 +860,7 @@ export function InternalResourceForm({
field.value ??
"http"
}
disabled={httpSectionDisabled}
>
<FormControl>
<SelectTrigger className="w-full">
@@ -893,6 +901,7 @@ export function InternalResourceForm({
<Input
{...field}
className="w-full"
disabled={isHttpMode && httpSectionDisabled}
/>
</FormControl>
<FormMessage />
@@ -948,6 +957,7 @@ export function InternalResourceForm({
field.value ??
""
}
disabled={httpSectionDisabled}
onChange={(e) => {
const raw =
e.target
@@ -981,6 +991,10 @@ export function InternalResourceForm({
</div>
</div>
{isHttpMode && (
<PaidFeaturesAlert tiers={tierMatrix.httpPrivateResources} />
)}
{isHttpMode ? (
<div className="space-y-4">
<div className="my-8">
@@ -991,6 +1005,7 @@ export function InternalResourceForm({
{t(httpConfigurationDescriptionKey)}
</div>
</div>
<div className={httpSectionDisabled ? "pointer-events-none opacity-50" : undefined}>
<DomainPicker
key={
variant === "edit" && siteResourceId
@@ -1039,6 +1054,7 @@ export function InternalResourceForm({
);
}}
/>
</div>
<FormField
control={form.control}
name="ssl"
@@ -1055,6 +1071,7 @@ export function InternalResourceForm({
onCheckedChange={
field.onChange
}
disabled={httpSectionDisabled}
/>
</FormControl>
</FormItem>