Compare commits

...

5 Commits

Author SHA1 Message Date
Owen aa41a63430 Dont run the acme in saas or when we control dns 2026-04-12 17:50:27 -07:00
Owen 0db55daff6 Merge branch 'private-http' of github.com:fosrl/pangolin into private-http 2026-04-12 17:47:59 -07:00
Owen 9b271950d2 Push down certs when they are detected 2026-04-12 17:31:51 -07:00
Owen 89b6b1fb56 Placeholder screen and certs are working 2026-04-12 16:49:49 -07:00
Owen 789b991c56 Logging and http working 2026-04-12 15:08:17 -07:00
21 changed files with 516 additions and 189 deletions
+1
View File
@@ -1004,6 +1004,7 @@ export const requestAuditLog = pgTable(
actor: text("actor"),
actorId: text("actorId"),
resourceId: integer("resourceId"),
siteResourceId: integer("siteResourceId"),
ip: text("ip"),
location: text("location"),
userAgent: text("userAgent"),
+1
View File
@@ -1104,6 +1104,7 @@ export const requestAuditLog = sqliteTable(
actor: text("actor"),
actorId: text("actorId"),
resourceId: integer("resourceId"),
siteResourceId: integer("siteResourceId"),
ip: text("ip"),
location: text("location"),
userAgent: text("userAgent"),
+4 -2
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"]
};
-39
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
+1 -1
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;
+194 -28
View File
@@ -13,11 +13,25 @@
import fs from "fs";
import crypto from "crypto";
import { certificates, domains, db } from "@server/db";
import {
certificates,
clients,
clientSiteResourcesAssociationsCache,
db,
domains,
newts,
SiteResource,
siteResources
} 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";
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[] };
@@ -32,12 +46,136 @@ interface AcmeJson {
};
}
function getEncryptionKey(): Buffer {
const keyHex = config.getRawPrivateConfig().server.encryption_key;
if (!keyHex) {
throw new Error("acmeCertSync: encryption key is not configured");
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.info(
`acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"`
);
for (const resource of affectedResources) {
try {
// Get the newt for this site
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, resource.siteId))
.limit(1);
if (!newt) {
logger.debug(
`acmeCertSync: no newt found for site ${resource.siteId}, skipping resource ${resource.siteResourceId}`
);
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 the new target (will read the freshly updated cert from DB)
const newTarget = await generateSubnetProxyTargetV2(
resource,
resourceClients
);
if (!newTarget) {
logger.debug(
`acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping`
);
continue;
}
// Construct the old target — 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 oldTarget: SubnetProxyTargetV2 = {
...newTarget,
tlsCert: oldCertPem ?? undefined,
tlsKey: oldKeyPem ?? undefined
};
await updateTargets(
newt.newtId,
{ oldTargets: [oldTarget], newTargets: [newTarget] },
newt.version
);
logger.info(
`acmeCertSync: pushed cert update to newt for site ${resource.siteId}, resource ${resource.siteResourceId}`
);
} catch (err) {
logger.error(
`acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}`
);
}
}
return Buffer.from(keyHex, "hex");
}
async function findDomainId(certDomain: string): Promise<string | null> {
@@ -99,9 +237,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 +257,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;
}
@@ -159,11 +293,14 @@ async function syncAcmeCerts(
.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 = decryptData(
const storedCertPem = decrypt(
existing[0].certFile,
encryptionKey
config.getRawConfig().server.secret!
);
if (storedCertPem === certPem) {
logger.debug(
@@ -171,6 +308,21 @@ async function syncAcmeCerts(
);
continue;
}
// Cert has changed; capture old values so we can send a correct
// update message to the newt after the DB write.
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) {
// Decryption failure means we should proceed with the update
logger.debug(
@@ -185,9 +337,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 +346,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);
@@ -228,6 +378,8 @@ async function syncAcmeCerts(
logger.info(
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await pushCertUpdateToAffectedNewts(domain, domainId, oldCertPem, oldKeyPem);
} else {
await db.insert(certificates).values({
domain,
@@ -244,21 +396,35 @@ async function syncAcmeCerts(
logger.info(
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
// For a brand-new cert, push to any SSL resources that were waiting for it
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
}
}
}
export function initAcmeCertSync(): void {
const privateConfig = config.getRawPrivateConfig();
if (build == "saas") {
logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`);
return;
}
if (!privateConfig.flags?.enable_acme_cert_sync) {
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 enabled, skipping`);
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 +440,4 @@ export function initAcmeCertSync(): void {
logger.error(`acmeCertSync: error during sync: ${err}`);
});
}, intervalMs);
}
}
+11 -18
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 {
+1 -5
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({}),
+27 -16
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
};
}
}
+9 -29
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,
@@ -92,9 +92,14 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
return;
}
// Look up the org for this site
// Look up the org for this site and check retention settings
const [site] = await db
.select({ orgId: sites.orgId, orgSubnet: orgs.subnet })
.select({
orgId: sites.orgId,
orgSubnet: orgs.subnet,
settingsLogRetentionDaysConnection:
orgs.settingsLogRetentionDaysConnection
})
.from(sites)
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
.where(eq(sites.siteId, newt.siteId));
@@ -108,6 +113,13 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
const orgId = site.orgId;
if (site.settingsLogRetentionDaysConnection === 0) {
logger.debug(
`Connection log retention is disabled for org ${orgId}, skipping`
);
return;
}
// Extract the CIDR suffix (e.g. "/16") from the org subnet so we can
// reconstruct the exact subnet string stored on each client record.
const cidrSuffix = site.orgSubnet?.includes("/")
@@ -13,12 +13,13 @@
import { db } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { sites, Newt, orgs } from "@server/db";
import { eq } from "drizzle-orm";
import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { inflate } from "zlib";
import { promisify } from "util";
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
import { getCountryCodeForIp } from "@server/lib/geoip";
export async function flushRequestLogToDb(): Promise<void> {
return;
@@ -81,6 +82,7 @@ export const handleRequestLogMessage: MessageHandler = async (context) => {
const [site] = await db
.select({
orgId: sites.orgId,
orgSubnet: orgs.subnet,
settingsLogRetentionDaysRequest:
orgs.settingsLogRetentionDaysRequest
})
@@ -118,6 +120,61 @@ export const handleRequestLogMessage: MessageHandler = async (context) => {
logger.debug(`Request log entries: ${JSON.stringify(entries)}`);
// Build a map from sourceIp → external endpoint string by joining clients
// with clientSitesAssociationsCache. The endpoint is the real-world IP:port
// of the client device and is used for GeoIP lookup.
const ipToEndpoint = new Map<string, string>();
const cidrSuffix = site.orgSubnet?.includes("/")
? site.orgSubnet.substring(site.orgSubnet.indexOf("/"))
: null;
if (cidrSuffix) {
const uniqueSourceAddrs = new Set<string>();
for (const entry of entries) {
if (entry.sourceAddr) {
uniqueSourceAddrs.add(entry.sourceAddr);
}
}
if (uniqueSourceAddrs.size > 0) {
const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => {
const ip = addr.includes(":") ? addr.split(":")[0] : addr;
return `${ip}${cidrSuffix}`;
});
const matchedClients = await db
.select({
subnet: clients.subnet,
endpoint: clientSitesAssociationsCache.endpoint
})
.from(clients)
.innerJoin(
clientSitesAssociationsCache,
and(
eq(
clientSitesAssociationsCache.clientId,
clients.clientId
),
eq(clientSitesAssociationsCache.siteId, newt.siteId)
)
)
.where(
and(
eq(clients.orgId, orgId),
inArray(clients.subnet, subnetQueries)
)
);
for (const c of matchedClients) {
if (c.endpoint) {
const ip = c.subnet.split("/")[0];
ipToEndpoint.set(ip, c.endpoint);
}
}
}
}
for (const entry of entries) {
if (
!entry.requestId ||
@@ -141,12 +198,27 @@ export const handleRequestLogMessage: MessageHandler = async (context) => {
entry.path +
(entry.rawQuery ? "?" + entry.rawQuery : "");
// Resolve the client's external endpoint for GeoIP lookup.
// sourceAddr is the WireGuard IP (possibly ip:port), so strip the port.
const sourceIp = entry.sourceAddr.includes(":")
? entry.sourceAddr.split(":")[0]
: entry.sourceAddr;
const endpoint = ipToEndpoint.get(sourceIp);
let location: string | undefined;
if (endpoint) {
const endpointIp = endpoint.includes(":")
? endpoint.split(":")[0]
: endpoint;
location = await getCountryCodeForIp(endpointIp);
}
await logRequestAudit(
{
action: true,
reason: 108,
resourceId: entry.resourceId,
orgId
siteResourceId: entry.resourceId,
orgId,
location
},
{
path: entry.path,
+111 -38
View File
@@ -1,8 +1,8 @@
import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db";
import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm";
import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
@@ -92,7 +92,10 @@ function getWhere(data: Q) {
lt(requestAuditLog.timestamp, data.timeEnd),
eq(requestAuditLog.orgId, data.orgId),
data.resourceId
? eq(requestAuditLog.resourceId, data.resourceId)
? or(
eq(requestAuditLog.resourceId, data.resourceId),
eq(requestAuditLog.siteResourceId, data.resourceId)
)
: undefined,
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
data.method ? eq(requestAuditLog.method, data.method) : undefined,
@@ -110,15 +113,16 @@ export function queryRequest(data: Q) {
return primaryLogsDb
.select({
id: requestAuditLog.id,
timestamp: requestAuditLog.timestamp,
orgId: requestAuditLog.orgId,
action: requestAuditLog.action,
reason: requestAuditLog.reason,
actorType: requestAuditLog.actorType,
actor: requestAuditLog.actor,
actorId: requestAuditLog.actorId,
resourceId: requestAuditLog.resourceId,
ip: requestAuditLog.ip,
timestamp: requestAuditLog.timestamp,
orgId: requestAuditLog.orgId,
action: requestAuditLog.action,
reason: requestAuditLog.reason,
actorType: requestAuditLog.actorType,
actor: requestAuditLog.actor,
actorId: requestAuditLog.actorId,
resourceId: requestAuditLog.resourceId,
siteResourceId: requestAuditLog.siteResourceId,
ip: requestAuditLog.ip,
location: requestAuditLog.location,
userAgent: requestAuditLog.userAgent,
metadata: requestAuditLog.metadata,
@@ -137,37 +141,73 @@ export function queryRequest(data: Q) {
}
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
// If logs database is the same as main database, we can do a join
// Otherwise, we need to fetch resource details separately
const resourceIds = logs
.map(log => log.resourceId)
.filter((id): id is number => id !== null && id !== undefined);
if (resourceIds.length === 0) {
const siteResourceIds = logs
.filter(log => log.resourceId == null && log.siteResourceId != null)
.map(log => log.siteResourceId)
.filter((id): id is number => id !== null && id !== undefined);
if (resourceIds.length === 0 && siteResourceIds.length === 0) {
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
}
// Fetch resource details from main database
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name,
niceId: resources.niceId
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
const resourceMap = new Map<number, { name: string | null; niceId: string | null }>();
// Create a map for quick lookup
const resourceMap = new Map(
resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }])
);
if (resourceIds.length > 0) {
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name,
niceId: resources.niceId
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
for (const r of resourceDetails) {
resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId });
}
}
const siteResourceMap = new Map<number, { name: string | null; niceId: string | null }>();
if (siteResourceIds.length > 0) {
const siteResourceDetails = await primaryDb
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name,
niceId: siteResources.niceId
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds));
for (const r of siteResourceDetails) {
siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId });
}
}
// Enrich logs with resource details
return logs.map(log => ({
...log,
resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null,
resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null
}));
return logs.map(log => {
if (log.resourceId != null) {
const details = resourceMap.get(log.resourceId);
return {
...log,
resourceName: details?.name ?? null,
resourceNiceId: details?.niceId ?? null
};
} else if (log.siteResourceId != null) {
const details = siteResourceMap.get(log.siteResourceId);
return {
...log,
resourceId: log.siteResourceId,
resourceName: details?.name ?? null,
resourceNiceId: details?.niceId ?? null
};
}
return { ...log, resourceName: null, resourceNiceId: null };
});
}
export function countRequestQuery(data: Q) {
@@ -211,7 +251,8 @@ async function queryUniqueFilterAttributes(
uniqueLocations,
uniqueHosts,
uniquePaths,
uniqueResources
uniqueResources,
uniqueSiteResources
] = await Promise.all([
primaryLogsDb
.selectDistinct({ actor: requestAuditLog.actor })
@@ -239,6 +280,13 @@ async function queryUniqueFilterAttributes(
})
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
primaryLogsDb
.selectDistinct({
id: requestAuditLog.siteResourceId
})
.from(requestAuditLog)
.where(and(baseConditions, isNull(requestAuditLog.resourceId)))
.limit(DISTINCT_LIMIT + 1)
]);
@@ -259,6 +307,10 @@ async function queryUniqueFilterAttributes(
.map(row => row.id)
.filter((id): id is number => id !== null);
const siteResourceIds = uniqueSiteResources
.map(row => row.id)
.filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
if (resourceIds.length > 0) {
@@ -270,10 +322,31 @@ async function queryUniqueFilterAttributes(
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
resourcesWithNames = resourceDetails.map(r => ({
id: r.resourceId,
name: r.name
}));
resourcesWithNames = [
...resourcesWithNames,
...resourceDetails.map(r => ({
id: r.resourceId,
name: r.name
}))
];
}
if (siteResourceIds.length > 0) {
const siteResourceDetails = await primaryDb
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds));
resourcesWithNames = [
...resourcesWithNames,
...siteResourceDetails.map(r => ({
id: r.siteResourceId,
name: r.name
}))
];
}
return {
+1
View File
@@ -28,6 +28,7 @@ export type QueryRequestAuditLogResponse = {
actor: string | null;
actorId: string | null;
resourceId: number | null;
siteResourceId: number | null;
resourceNiceId: string | null;
resourceName: string | null;
ip: string | null;
+3
View File
@@ -39,6 +39,7 @@ const auditLogBuffer: Array<{
metadata: any;
action: boolean;
resourceId?: number;
siteResourceId?: number;
reason: number;
location?: string;
originalRequestURL: string;
@@ -187,6 +188,7 @@ export async function logRequestAudit(
action: boolean;
reason: number;
resourceId?: number;
siteResourceId?: number;
orgId?: string;
location?: string;
user?: { username: string; userId: string };
@@ -263,6 +265,7 @@ export async function logRequestAudit(
metadata: sanitizeString(metadata),
action: data.action,
resourceId: data.resourceId,
siteResourceId: data.siteResourceId,
reason: data.reason,
location: sanitizeString(data.location),
originalRequestURL: sanitizeString(body.originalRequestURL) ?? "",
@@ -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()
@@ -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
@@ -512,7 +512,11 @@ export default function GeneralPage() {
cell: ({ row }) => {
return (
<Link
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
href={
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
? `/${row.original.orgId}/settings/resources/client?query=${row.original.resourceNiceId}`
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
}
onClick={(e) => e.stopPropagation()}
>
<Button variant="outline" size="sm">
@@ -43,6 +43,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);
@@ -156,6 +157,7 @@ export default function CreateInternalResourceDialog({
orgId={orgId}
formId="create-internal-resource-form"
onSubmit={handleSubmit}
onSubmitDisabledChange={setIsHttpModeDisabled}
/>
</CredenzaBody>
<CredenzaFooter>
@@ -171,7 +173,7 @@ export default function CreateInternalResourceDialog({
<Button
type="submit"
form="create-internal-resource-form"
disabled={isSubmitting}
disabled={isSubmitting || isHttpModeDisabled}
loading={isSubmitting}
>
{t("createInternalResourceDialogCreateResource")}
@@ -48,6 +48,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 {
@@ -178,6 +179,7 @@ export default function EditInternalResourceDialog({
onSubmit={(values) =>
startTransition(() => handleSubmit(values))
}
onSubmitDisabledChange={setIsHttpModeDisabled}
/>
</CredenzaBody>
<CredenzaFooter>
@@ -193,7 +195,7 @@ export default function EditInternalResourceDialog({
<Button
type="submit"
form="edit-internal-resource-form"
disabled={isSubmitting}
disabled={isSubmitting || isHttpModeDisabled}
loading={isSubmitting}
>
{t("editInternalResourceDialogSaveResource")}
+18 -1
View File
@@ -200,6 +200,7 @@ type InternalResourceFormProps = {
siteResourceId?: number;
formId: string;
onSubmit: (values: InternalResourceFormValues) => void | Promise<void>;
onSubmitDisabledChange?: (disabled: boolean) => void;
};
export function InternalResourceForm({
@@ -209,13 +210,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"
@@ -666,6 +669,10 @@ export function InternalResourceForm({
form
]);
useEffect(() => {
onSubmitDisabledChange?.(isHttpMode && httpSectionDisabled);
}, [isHttpMode, httpSectionDisabled, onSubmitDisabledChange]);
return (
<Form {...form}>
<form
@@ -875,6 +882,7 @@ export function InternalResourceForm({
field.value ??
"http"
}
disabled={httpSectionDisabled}
>
<FormControl>
<SelectTrigger className="w-full">
@@ -915,6 +923,7 @@ export function InternalResourceForm({
<Input
{...field}
className="w-full"
disabled={isHttpMode && httpSectionDisabled}
/>
</FormControl>
<FormMessage />
@@ -970,6 +979,7 @@ export function InternalResourceForm({
field.value ??
""
}
disabled={httpSectionDisabled}
onChange={(e) => {
const raw =
e.target
@@ -1003,6 +1013,10 @@ export function InternalResourceForm({
</div>
</div>
{isHttpMode && (
<PaidFeaturesAlert tiers={tierMatrix.httpPrivateResources} />
)}
{isHttpMode ? (
<div className="space-y-4">
<div className="my-8">
@@ -1013,6 +1027,7 @@ export function InternalResourceForm({
{t(httpConfigurationDescriptionKey)}
</div>
</div>
<div className={httpSectionDisabled ? "pointer-events-none opacity-50" : undefined}>
<DomainPicker
key={
variant === "edit" && siteResourceId
@@ -1061,6 +1076,7 @@ export function InternalResourceForm({
);
}}
/>
</div>
<FormField
control={form.control}
name="ssl"
@@ -1077,6 +1093,7 @@ export function InternalResourceForm({
onCheckedChange={
field.onChange
}
disabled={httpSectionDisabled}
/>
</FormControl>
</FormItem>