mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-13 05:16:37 +00:00
Placeholder screen and certs are working
This commit is contained in:
@@ -19,7 +19,8 @@ export enum TierFeature {
|
|||||||
SshPam = "sshPam",
|
SshPam = "sshPam",
|
||||||
FullRbac = "fullRbac",
|
FullRbac = "fullRbac",
|
||||||
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
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[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
@@ -56,5 +57,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
|
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
|
||||||
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
||||||
[TierFeature.SIEM]: ["enterprise"]
|
[TierFeature.SIEM]: ["enterprise"],
|
||||||
|
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -5,7 +5,7 @@ import config from "@server/lib/config";
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { getValidCertificatesForDomains } from "#private/lib/certificates";
|
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
|
||||||
|
|
||||||
interface IPRange {
|
interface IPRange {
|
||||||
start: bigint;
|
start: bigint;
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import fs from "fs";
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { certificates, domains, db } from "@server/db";
|
import { certificates, domains, db } from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
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 logger from "@server/logger";
|
||||||
import config from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
interface AcmeCert {
|
interface AcmeCert {
|
||||||
domain: { main: string; sans?: string[] };
|
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> {
|
async function findDomainId(certDomain: string): Promise<string | null> {
|
||||||
// Strip wildcard prefix before lookup (*.example.com -> example.com)
|
// Strip wildcard prefix before lookup (*.example.com -> example.com)
|
||||||
const lookupDomain = certDomain.startsWith("*.")
|
const lookupDomain = certDomain.startsWith("*.")
|
||||||
@@ -99,9 +92,7 @@ async function syncAcmeCerts(
|
|||||||
try {
|
try {
|
||||||
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug(
|
logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`);
|
||||||
`acmeCertSync: could not read ${acmeJsonPath}: ${err}`
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,15 +112,13 @@ async function syncAcmeCerts(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptionKey = getEncryptionKey();
|
|
||||||
|
|
||||||
for (const cert of resolverData.Certificates) {
|
for (const cert of resolverData.Certificates) {
|
||||||
const domain = cert.domain?.main;
|
const domain = cert.domain?.main;
|
||||||
|
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
logger.debug(
|
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
||||||
`acmeCertSync: skipping cert with missing domain`
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,9 +150,9 @@ async function syncAcmeCerts(
|
|||||||
|
|
||||||
if (existing.length > 0 && existing[0].certFile) {
|
if (existing.length > 0 && existing[0].certFile) {
|
||||||
try {
|
try {
|
||||||
const storedCertPem = decryptData(
|
const storedCertPem = decrypt(
|
||||||
existing[0].certFile,
|
existing[0].certFile,
|
||||||
encryptionKey
|
config.getRawConfig().server.secret!
|
||||||
);
|
);
|
||||||
if (storedCertPem === certPem) {
|
if (storedCertPem === certPem) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -185,9 +174,7 @@ async function syncAcmeCerts(
|
|||||||
if (firstCertPem) {
|
if (firstCertPem) {
|
||||||
try {
|
try {
|
||||||
const x509 = new crypto.X509Certificate(firstCertPem);
|
const x509 = new crypto.X509Certificate(firstCertPem);
|
||||||
expiresAt = Math.floor(
|
expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000);
|
||||||
new Date(x509.validTo).getTime() / 1000
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||||
@@ -196,8 +183,8 @@ async function syncAcmeCerts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wildcard = domain.startsWith("*.");
|
const wildcard = domain.startsWith("*.");
|
||||||
const encryptedCert = encryptData(certPem, encryptionKey);
|
const encryptedCert = encrypt(certPem, config.getRawConfig().server.secret!);
|
||||||
const encryptedKey = encryptData(keyPem, encryptionKey);
|
const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!);
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
const domainId = await findDomainId(domain);
|
const domainId = await findDomainId(domain);
|
||||||
@@ -249,16 +236,16 @@ async function syncAcmeCerts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initAcmeCertSync(): void {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const acmeJsonPath =
|
const acmeJsonPath =
|
||||||
privateConfig.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
|
privateConfigData.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
|
||||||
const resolver = privateConfig.acme?.resolver ?? "letsencrypt";
|
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
|
||||||
const intervalMs = privateConfig.acme?.sync_interval_ms ?? 5000;
|
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
|
`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}`);
|
logger.error(`acmeCertSync: error during sync: ${err}`);
|
||||||
});
|
});
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,23 +11,15 @@
|
|||||||
* This file is not licensed under the AGPLv3.
|
* 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 { certificates, db } from "@server/db";
|
||||||
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
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 logger from "@server/logger";
|
||||||
import cache from "#private/lib/cache";
|
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
|
// Define the return type for clarity and type safety
|
||||||
export type CertificateResult = {
|
export type CertificateResult = {
|
||||||
@@ -45,7 +37,7 @@ export async function getValidCertificatesForDomains(
|
|||||||
domains: Set<string>,
|
domains: Set<string>,
|
||||||
useCache: boolean = true
|
useCache: boolean = true
|
||||||
): Promise<Array<CertificateResult>> {
|
): Promise<Array<CertificateResult>> {
|
||||||
loadEncryptData(); // Ensure encryption key is loaded
|
|
||||||
|
|
||||||
const finalResults: CertificateResult[] = [];
|
const finalResults: CertificateResult[] = [];
|
||||||
const domainsToQuery = new Set<string>();
|
const domainsToQuery = new Set<string>();
|
||||||
@@ -68,7 +60,7 @@ export async function getValidCertificatesForDomains(
|
|||||||
|
|
||||||
// 2. If all domains were resolved from the cache, return early
|
// 2. If all domains were resolved from the cache, return early
|
||||||
if (domainsToQuery.size === 0) {
|
if (domainsToQuery.size === 0) {
|
||||||
const decryptedResults = decryptFinalResults(finalResults);
|
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
||||||
return decryptedResults;
|
return decryptedResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,22 +165,23 @@ export async function getValidCertificatesForDomains(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedResults = decryptFinalResults(finalResults);
|
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
||||||
return decryptedResults;
|
return decryptedResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decryptFinalResults(
|
function decryptFinalResults(
|
||||||
finalResults: CertificateResult[]
|
finalResults: CertificateResult[],
|
||||||
|
secret: string
|
||||||
): CertificateResult[] {
|
): CertificateResult[] {
|
||||||
const validCertsDecrypted = finalResults.map((cert) => {
|
const validCertsDecrypted = finalResults.map((cert) => {
|
||||||
// Decrypt and save certificate file
|
// Decrypt and save certificate file
|
||||||
const decryptedCert = decryptData(
|
const decryptedCert = decrypt(
|
||||||
cert.certFile!, // is not null from query
|
cert.certFile!, // is not null from query
|
||||||
encryptionKey
|
secret
|
||||||
);
|
);
|
||||||
|
|
||||||
// Decrypt and save key file
|
// 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 only the certificate data without org information
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ export const privateConfigSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
server: z
|
server: z
|
||||||
.object({
|
.object({
|
||||||
encryption_key: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
|
||||||
reo_client_id: z
|
reo_client_id: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -96,7 +92,7 @@ export const privateConfigSchema = z.object({
|
|||||||
enable_redis: z.boolean().optional().default(false),
|
enable_redis: z.boolean().optional().default(false),
|
||||||
use_pangolin_dns: z.boolean().optional().default(false),
|
use_pangolin_dns: z.boolean().optional().default(false),
|
||||||
use_org_only_idp: z.boolean().optional(),
|
use_org_only_idp: z.boolean().optional(),
|
||||||
enable_acme_cert_sync: z.boolean().optional().default(false)
|
enable_acme_cert_sync: z.boolean().optional().default(true)
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
|
|||||||
@@ -268,10 +268,10 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge
|
// Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge
|
||||||
const siteResourcesWithAliases = await db
|
const siteResourcesWithFullDomain = await db
|
||||||
.select({
|
.select({
|
||||||
siteResourceId: siteResources.siteResourceId,
|
siteResourceId: siteResources.siteResourceId,
|
||||||
alias: siteResources.alias,
|
fullDomain: siteResources.fullDomain,
|
||||||
mode: siteResources.mode
|
mode: siteResources.mode
|
||||||
})
|
})
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
@@ -279,7 +279,7 @@ export async function getTraefikConfig(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(siteResources.enabled, true),
|
eq(siteResources.enabled, true),
|
||||||
isNotNull(siteResources.alias),
|
isNotNull(siteResources.fullDomain),
|
||||||
eq(siteResources.mode, "http"),
|
eq(siteResources.mode, "http"),
|
||||||
eq(siteResources.ssl, true),
|
eq(siteResources.ssl, true),
|
||||||
or(
|
or(
|
||||||
@@ -305,9 +305,9 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Include siteResource aliases so pangolin-dns also fetches certs for them
|
// Include siteResource aliases so pangolin-dns also fetches certs for them
|
||||||
for (const sr of siteResourcesWithAliases) {
|
for (const sr of siteResourcesWithFullDomain) {
|
||||||
if (sr.alias) {
|
if (sr.fullDomain) {
|
||||||
domains.add(sr.alias);
|
domains.add(sr.fullDomain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// get the valid certs for these domains
|
// 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
|
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
||||||
// Traefik generates TLS certificates for those domains even when no
|
// Traefik generates TLS certificates for those domains even when no
|
||||||
// matching resource exists yet.
|
// matching resource exists yet.
|
||||||
if (siteResourcesWithAliases.length > 0) {
|
if (siteResourcesWithFullDomain.length > 0) {
|
||||||
// Build a set of domains already covered by normal resources
|
// Build a set of domains already covered by normal resources
|
||||||
const existingFullDomains = new Set<string>();
|
const existingFullDomains = new Set<string>();
|
||||||
for (const resource of resourcesMap.values()) {
|
for (const resource of resourcesMap.values()) {
|
||||||
@@ -913,13 +913,13 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const sr of siteResourcesWithAliases) {
|
for (const sr of siteResourcesWithFullDomain) {
|
||||||
if (!sr.alias) continue;
|
if (!sr.fullDomain) continue;
|
||||||
|
|
||||||
// Skip if this alias is already handled by a resource router
|
// 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 srKey = `site-resource-cert-${sr.siteResourceId}`;
|
||||||
const siteResourceServiceName = `${srKey}-service`;
|
const siteResourceServiceName = `${srKey}-service`;
|
||||||
const siteResourceRouterName = `${srKey}-router`;
|
const siteResourceRouterName = `${srKey}-router`;
|
||||||
@@ -970,7 +970,7 @@ export async function getTraefikConfig(
|
|||||||
],
|
],
|
||||||
middlewares: [redirectHttpsMiddlewareName],
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
service: siteResourceServiceName,
|
service: siteResourceServiceName,
|
||||||
rule: `Host(\`${alias}\`)`,
|
rule: `Host(\`${fullDomain}\`)`,
|
||||||
priority: 100
|
priority: 100
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -979,7 +979,7 @@ export async function getTraefikConfig(
|
|||||||
if (
|
if (
|
||||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||||
) {
|
) {
|
||||||
const domainParts = alias.split(".");
|
const domainParts = fullDomain.split(".");
|
||||||
const wildCard =
|
const wildCard =
|
||||||
domainParts.length <= 2
|
domainParts.length <= 2
|
||||||
? `*.${domainParts.join(".")}`
|
? `*.${domainParts.join(".")}`
|
||||||
@@ -999,11 +999,11 @@ export async function getTraefikConfig(
|
|||||||
} else {
|
} else {
|
||||||
// pangolin-dns: only add route if we already have a valid cert
|
// pangolin-dns: only add route if we already have a valid cert
|
||||||
const matchingCert = validCerts.find(
|
const matchingCert = validCerts.find(
|
||||||
(cert) => cert.queriedDomain === alias
|
(cert) => cert.queriedDomain === fullDomain
|
||||||
);
|
);
|
||||||
if (!matchingCert) {
|
if (!matchingCert) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`No matching certificate found for siteResource alias: ${alias}`
|
`No matching certificate found for siteResource alias: ${fullDomain}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1016,10 +1016,21 @@ export async function getTraefikConfig(
|
|||||||
],
|
],
|
||||||
service: siteResourceServiceName,
|
service: siteResourceServiceName,
|
||||||
middlewares: [siteResourceRewriteMiddlewareName],
|
middlewares: [siteResourceRewriteMiddlewareName],
|
||||||
rule: `Host(\`${alias}\`)`,
|
rule: `Host(\`${fullDomain}\`)`,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
tls
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,8 @@ import {
|
|||||||
User,
|
User,
|
||||||
certificates,
|
certificates,
|
||||||
exitNodeOrgs,
|
exitNodeOrgs,
|
||||||
RemoteExitNode,
|
|
||||||
olms,
|
|
||||||
newts,
|
|
||||||
clients,
|
|
||||||
sites,
|
|
||||||
domains,
|
domains,
|
||||||
orgDomains,
|
orgDomains,
|
||||||
targets,
|
|
||||||
loginPage,
|
loginPage,
|
||||||
loginPageOrg,
|
loginPageOrg,
|
||||||
LoginPage,
|
LoginPage,
|
||||||
@@ -70,12 +64,9 @@ import {
|
|||||||
updateAndGenerateEndpointDestinations,
|
updateAndGenerateEndpointDestinations,
|
||||||
updateSiteBandwidth
|
updateSiteBandwidth
|
||||||
} from "@server/routers/gerbil";
|
} from "@server/routers/gerbil";
|
||||||
import * as gerbil from "@server/routers/gerbil";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { decryptData } from "@server/lib/encryption";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import privateConfig from "#private/lib/config";
|
|
||||||
import * as fs from "fs";
|
|
||||||
import { exchangeSession } from "@server/routers/badger";
|
import { exchangeSession } from "@server/routers/badger";
|
||||||
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
||||||
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
|
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)
|
// Get valid certificates for given domains (supports wildcard certs)
|
||||||
hybridRouter.get(
|
hybridRouter.get(
|
||||||
"/certificates/domains",
|
"/certificates/domains",
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
loadEncryptData(); // Ensure encryption key is loaded
|
|
||||||
|
|
||||||
const parsed = getCertificatesByDomainsQuerySchema.safeParse(
|
const parsed = getCertificatesByDomainsQuerySchema.safeParse(
|
||||||
req.query
|
req.query
|
||||||
);
|
);
|
||||||
@@ -447,13 +424,13 @@ hybridRouter.get(
|
|||||||
|
|
||||||
const result = filtered.map((cert) => {
|
const result = filtered.map((cert) => {
|
||||||
// Decrypt and save certificate file
|
// Decrypt and save certificate file
|
||||||
const decryptedCert = decryptData(
|
const decryptedCert = decrypt(
|
||||||
cert.certFile!, // is not null from query
|
cert.certFile!, // is not null from query
|
||||||
encryptionKey
|
config.getRawConfig().server.secret!
|
||||||
);
|
);
|
||||||
|
|
||||||
// Decrypt and save key file
|
// 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 only the certificate data without org information
|
||||||
return {
|
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,
|
data: userOrgRoleRows,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
portRangeStringSchema
|
portRangeStringSchema
|
||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
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 { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
@@ -201,6 +201,21 @@ export async function createSiteResource(
|
|||||||
subdomain
|
subdomain
|
||||||
} = parsedBody.data;
|
} = 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
|
// Verify the site exists and belongs to the org
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import {
|
import {
|
||||||
clientSiteResources,
|
clientSiteResources,
|
||||||
clientSiteResourcesAssociationsCache,
|
clientSiteResourcesAssociationsCache,
|
||||||
@@ -13,7 +12,8 @@ import {
|
|||||||
Transaction,
|
Transaction,
|
||||||
userSiteResources
|
userSiteResources
|
||||||
} from "@server/db";
|
} 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 { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||||
import {
|
import {
|
||||||
generateAliasConfig,
|
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(
|
const isLicensedSshPam = await isLicensedOrSubscribed(
|
||||||
existingSiteResource.orgId,
|
existingSiteResource.orgId,
|
||||||
tierMatrix.sshPam
|
tierMatrix.sshPam
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default function CreateInternalResourceDialog({
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(values: InternalResourceFormValues) {
|
async function handleSubmit(values: InternalResourceFormValues) {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
@@ -159,6 +160,7 @@ export default function CreateInternalResourceDialog({
|
|||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
formId="create-internal-resource-form"
|
formId="create-internal-resource-form"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||||
/>
|
/>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
@@ -174,7 +176,7 @@ export default function CreateInternalResourceDialog({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="create-internal-resource-form"
|
form="create-internal-resource-form"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || isHttpModeDisabled}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
{t("createInternalResourceDialogCreateResource")}
|
{t("createInternalResourceDialogCreateResource")}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default function EditInternalResourceDialog({
|
|||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isSubmitting, startTransition] = useTransition();
|
const [isSubmitting, startTransition] = useTransition();
|
||||||
|
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(values: InternalResourceFormValues) {
|
async function handleSubmit(values: InternalResourceFormValues) {
|
||||||
try {
|
try {
|
||||||
@@ -177,6 +178,7 @@ export default function EditInternalResourceDialog({
|
|||||||
onSubmit={(values) =>
|
onSubmit={(values) =>
|
||||||
startTransition(() => handleSubmit(values))
|
startTransition(() => handleSubmit(values))
|
||||||
}
|
}
|
||||||
|
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||||
/>
|
/>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
@@ -192,7 +194,7 @@ export default function EditInternalResourceDialog({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="edit-internal-resource-form"
|
form="edit-internal-resource-form"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || isHttpModeDisabled}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
{t("editInternalResourceDialogSaveResource")}
|
{t("editInternalResourceDialogSaveResource")}
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ type InternalResourceFormProps = {
|
|||||||
siteResourceId?: number;
|
siteResourceId?: number;
|
||||||
formId: string;
|
formId: string;
|
||||||
onSubmit: (values: InternalResourceFormValues) => void | Promise<void>;
|
onSubmit: (values: InternalResourceFormValues) => void | Promise<void>;
|
||||||
|
onSubmitDisabledChange?: (disabled: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InternalResourceForm({
|
export function InternalResourceForm({
|
||||||
@@ -195,13 +196,15 @@ export function InternalResourceForm({
|
|||||||
orgId,
|
orgId,
|
||||||
siteResourceId,
|
siteResourceId,
|
||||||
formId,
|
formId,
|
||||||
onSubmit
|
onSubmit,
|
||||||
|
onSubmitDisabledChange
|
||||||
}: InternalResourceFormProps) {
|
}: InternalResourceFormProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
|
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
|
||||||
const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam);
|
const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam);
|
||||||
|
const httpSectionDisabled = !isPaidUser(tierMatrix.httpPrivateResources);
|
||||||
|
|
||||||
const nameRequiredKey =
|
const nameRequiredKey =
|
||||||
variant === "create"
|
variant === "create"
|
||||||
@@ -647,6 +650,10 @@ export function InternalResourceForm({
|
|||||||
form
|
form
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSubmitDisabledChange?.(isHttpMode && httpSectionDisabled);
|
||||||
|
}, [isHttpMode, httpSectionDisabled, onSubmitDisabledChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
@@ -853,6 +860,7 @@ export function InternalResourceForm({
|
|||||||
field.value ??
|
field.value ??
|
||||||
"http"
|
"http"
|
||||||
}
|
}
|
||||||
|
disabled={httpSectionDisabled}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
@@ -893,6 +901,7 @@ export function InternalResourceForm({
|
|||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
disabled={isHttpMode && httpSectionDisabled}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -948,6 +957,7 @@ export function InternalResourceForm({
|
|||||||
field.value ??
|
field.value ??
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
disabled={httpSectionDisabled}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw =
|
const raw =
|
||||||
e.target
|
e.target
|
||||||
@@ -981,6 +991,10 @@ export function InternalResourceForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isHttpMode && (
|
||||||
|
<PaidFeaturesAlert tiers={tierMatrix.httpPrivateResources} />
|
||||||
|
)}
|
||||||
|
|
||||||
{isHttpMode ? (
|
{isHttpMode ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="my-8">
|
<div className="my-8">
|
||||||
@@ -991,6 +1005,7 @@ export function InternalResourceForm({
|
|||||||
{t(httpConfigurationDescriptionKey)}
|
{t(httpConfigurationDescriptionKey)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={httpSectionDisabled ? "pointer-events-none opacity-50" : undefined}>
|
||||||
<DomainPicker
|
<DomainPicker
|
||||||
key={
|
key={
|
||||||
variant === "edit" && siteResourceId
|
variant === "edit" && siteResourceId
|
||||||
@@ -1039,6 +1054,7 @@ export function InternalResourceForm({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="ssl"
|
name="ssl"
|
||||||
@@ -1055,6 +1071,7 @@ export function InternalResourceForm({
|
|||||||
onCheckedChange={
|
onCheckedChange={
|
||||||
field.onChange
|
field.onChange
|
||||||
}
|
}
|
||||||
|
disabled={httpSectionDisabled}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
Reference in New Issue
Block a user