mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-22 12:10:35 +02:00
Merge branch 'dev' into feat/ip-filtering
This commit is contained in:
@@ -18,12 +18,16 @@ import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthM
|
||||
import { flushConnectionLogToDb } from "#private/routers/newt";
|
||||
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
|
||||
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
|
||||
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
|
||||
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
|
||||
|
||||
async function cleanup() {
|
||||
await stopPingAccumulator();
|
||||
await flushBandwidthToDb();
|
||||
await flushConnectionLogToDb();
|
||||
await flushSiteBandwidthToDb();
|
||||
await shutdownUsageRecorder();
|
||||
await shutdownAiSessionLogger();
|
||||
await rateLimitService.cleanup();
|
||||
await wsCleanup();
|
||||
await logStreamingManager.shutdown();
|
||||
|
||||
@@ -1,888 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
certificates,
|
||||
clients,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
db,
|
||||
domains,
|
||||
newts,
|
||||
siteNetworks,
|
||||
SiteResource,
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
generateSubnetProxyTargetV2,
|
||||
SubnetProxyTargetV2
|
||||
} from "@server/lib/ip";
|
||||
import { updateTargets } from "@server/routers/client/targets";
|
||||
import cache from "#private/lib/cache";
|
||||
import { build } from "@server/build";
|
||||
|
||||
interface AcmeCert {
|
||||
domain: { main: string; sans?: string[] };
|
||||
certificate: string;
|
||||
key: string;
|
||||
Store: string;
|
||||
}
|
||||
|
||||
interface AcmeJson {
|
||||
[resolver: string]: {
|
||||
Certificates: AcmeCert[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function pushCertUpdateToAffectedNewts(
|
||||
domain: string,
|
||||
domainId: string | null,
|
||||
oldCertPem: string | null,
|
||||
oldKeyPem: string | null
|
||||
): Promise<void> {
|
||||
// Find all SSL-enabled HTTP site resources that use this cert's domain
|
||||
let affectedResources: SiteResource[] = [];
|
||||
|
||||
if (domainId) {
|
||||
affectedResources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.domainId, domainId),
|
||||
eq(siteResources.ssl, true)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Fallback: match by exact fullDomain when no domainId is available
|
||||
affectedResources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.fullDomain, domain),
|
||||
eq(siteResources.ssl, true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (affectedResources.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no affected site resources for cert domain "${domain}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"`
|
||||
);
|
||||
|
||||
for (const resource of affectedResources) {
|
||||
try {
|
||||
// Get all sites for this resource via siteNetworks
|
||||
const resourceSiteRows = resource.networkId
|
||||
? await db
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, resource.networkId))
|
||||
: [];
|
||||
|
||||
if (resourceSiteRows.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no sites for resource ${resource.siteResourceId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get all clients with access to this resource
|
||||
const resourceClients = await db
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.innerJoin(
|
||||
clientSiteResourcesAssociationsCache,
|
||||
eq(
|
||||
clients.clientId,
|
||||
clientSiteResourcesAssociationsCache.clientId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
resource.siteResourceId
|
||||
)
|
||||
);
|
||||
|
||||
if (resourceClients.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data
|
||||
if (resource.fullDomain) {
|
||||
await cache.del(`cert:${resource.fullDomain}`);
|
||||
}
|
||||
|
||||
// Generate target once - same cert applies to all sites for this resource
|
||||
const newTargets = await generateSubnetProxyTargetV2(
|
||||
resource,
|
||||
resourceClients
|
||||
);
|
||||
|
||||
if (!newTargets) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Construct the old targets - same routing shape but with the previous cert/key.
|
||||
// The newt only uses destPrefix/sourcePrefixes for removal, but we keep the
|
||||
// semantics correct so the update message accurately reflects what changed.
|
||||
const oldTargets: SubnetProxyTargetV2[] = newTargets.map((t) => ({
|
||||
...t,
|
||||
tlsCert: oldCertPem ?? undefined,
|
||||
tlsKey: oldKeyPem ?? undefined
|
||||
}));
|
||||
|
||||
// Push update to each site's newt
|
||||
for (const { siteId } of resourceSiteRows) {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no newt found for site ${siteId}, skipping resource ${resource.siteResourceId}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await updateTargets(
|
||||
newt.newtId,
|
||||
{ oldTargets: oldTargets, newTargets: newTargets },
|
||||
newt.version
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findDomainId(certDomain: string): Promise<string | null> {
|
||||
// Strip wildcard prefix before lookup (*.example.com -> example.com)
|
||||
const lookupDomain = certDomain.startsWith("*.")
|
||||
? certDomain.slice(2)
|
||||
: certDomain;
|
||||
|
||||
// 1. Exact baseDomain match (any domain type)
|
||||
const exactMatch = await db
|
||||
.select({ domainId: domains.domainId })
|
||||
.from(domains)
|
||||
.where(eq(domains.baseDomain, lookupDomain))
|
||||
.limit(1);
|
||||
|
||||
if (exactMatch.length > 0) {
|
||||
return exactMatch[0].domainId;
|
||||
}
|
||||
|
||||
// 2. Walk up the domain hierarchy looking for a wildcard-type domain whose
|
||||
// baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com"
|
||||
// matches a wildcard domain with baseDomain "example.com".
|
||||
const parts = lookupDomain.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const candidate = parts.slice(i).join(".");
|
||||
if (!candidate) continue;
|
||||
|
||||
const wildcardMatch = await db
|
||||
.select({ domainId: domains.domainId })
|
||||
.from(domains)
|
||||
.where(
|
||||
and(
|
||||
eq(domains.baseDomain, candidate),
|
||||
eq(domains.type, "wildcard")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (wildcardMatch.length > 0) {
|
||||
return wildcardMatch[0].domainId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractFirstCert(pemBundle: string): string | null {
|
||||
const match = pemBundle.match(
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
|
||||
);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an ACME cert entry represents a wildcard cert by checking
|
||||
* both the primary domain (`main`) and the SANs. Some ACME clients (notably
|
||||
* Traefik) store the bare apex in `main` and only put the wildcard form in
|
||||
* `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]).
|
||||
*/
|
||||
function detectWildcard(
|
||||
main: string,
|
||||
sans: string[] | undefined
|
||||
): { wildcard: boolean; wildcardSan: string | null } {
|
||||
if (main.startsWith("*.")) {
|
||||
return { wildcard: true, wildcardSan: null };
|
||||
}
|
||||
if (Array.isArray(sans)) {
|
||||
for (const san of sans) {
|
||||
if (typeof san !== "string") continue;
|
||||
if (san === `*.${main}` || san.startsWith("*.")) {
|
||||
return { wildcard: true, wildcardSan: san };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { wildcard: false, wildcardSan: null };
|
||||
}
|
||||
|
||||
interface HttpCert {
|
||||
wildcard: boolean;
|
||||
altName: string;
|
||||
certName: string;
|
||||
commonName: string;
|
||||
certFile: string;
|
||||
keyFile: string;
|
||||
}
|
||||
|
||||
async function syncAcmeCertsFromHttp(endpoint: string): Promise<void> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(endpoint);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.debug(
|
||||
`acmeCertSync: HTTP endpoint returned status ${response.status}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let httpCerts: HttpCert[];
|
||||
try {
|
||||
httpCerts = await response.json();
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not parse JSON from HTTP endpoint: ${err}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(httpCerts) || httpCerts.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no certificates returned from HTTP endpoint`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const cert of httpCerts) {
|
||||
const domain = cert?.certName;
|
||||
|
||||
if (!domain || typeof domain !== "string") {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping HTTP cert with missing certName`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const certPem = cert.certFile;
|
||||
const keyPem = cert.keyFile;
|
||||
|
||||
if (!certPem?.trim() || !keyPem?.trim()) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstCertPemForValidation = extractFirstCert(certPem);
|
||||
if (!firstCertPemForValidation) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let validatedX509: crypto.X509Certificate;
|
||||
try {
|
||||
validatedX509 = new crypto.X509Certificate(
|
||||
firstCertPemForValidation
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
crypto.createPrivateKey(keyPem);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const wildcard = cert.wildcard ?? false;
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.domain, domain))
|
||||
.limit(1);
|
||||
|
||||
let oldCertPem: string | null = null;
|
||||
let oldKeyPem: string | null = null;
|
||||
|
||||
if (existing.length > 0 && existing[0].certFile) {
|
||||
try {
|
||||
const storedCertPem = decrypt(
|
||||
existing[0].certFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||
continue;
|
||||
}
|
||||
oldCertPem = storedCertPem;
|
||||
if (existing[0].keyFile) {
|
||||
try {
|
||||
oldKeyPem = decrypt(
|
||||
existing[0].keyFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
} catch (keyErr) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let expiresAt: number | null = null;
|
||||
try {
|
||||
expiresAt = Math.floor(
|
||||
new Date(validatedX509.validTo).getTime() / 1000
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedCert = encrypt(
|
||||
certPem,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const encryptedKey = encrypt(
|
||||
keyPem,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const domainId = await findDomainId(domain);
|
||||
if (domainId) {
|
||||
logger.debug(
|
||||
`acmeCertSync: resolved domainId "${domainId}" for HTTP cert domain "${domain}"`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
wildcard,
|
||||
...(domainId !== null && { domainId })
|
||||
})
|
||||
.where(eq(certificates.domain, domain));
|
||||
|
||||
await pushCertUpdateToAffectedNewts(
|
||||
domain,
|
||||
domainId,
|
||||
oldCertPem,
|
||||
oldKeyPem
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
await db.insert(certificates).values({
|
||||
domain,
|
||||
domainId,
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
wildcard
|
||||
});
|
||||
|
||||
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function storeCertForDomain(
|
||||
domain: string,
|
||||
certPem: string,
|
||||
keyPem: string,
|
||||
validatedX509: crypto.X509Certificate
|
||||
): Promise<void> {
|
||||
const wildcard = domain.startsWith("*.");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.domain, domain))
|
||||
.limit(1);
|
||||
|
||||
let oldCertPem: string | null = null;
|
||||
let oldKeyPem: string | null = null;
|
||||
|
||||
if (existing.length > 0 && existing[0].certFile) {
|
||||
try {
|
||||
const storedCertPem = decrypt(
|
||||
existing[0].certFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||
return;
|
||||
}
|
||||
oldCertPem = storedCertPem;
|
||||
if (existing[0].keyFile) {
|
||||
try {
|
||||
oldKeyPem = decrypt(
|
||||
existing[0].keyFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
} catch (keyErr) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let expiresAt: number | null = null;
|
||||
try {
|
||||
expiresAt = Math.floor(
|
||||
new Date(validatedX509.validTo).getTime() / 1000
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedCert = encrypt(
|
||||
certPem,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const domainId = await findDomainId(domain);
|
||||
if (domainId) {
|
||||
logger.debug(
|
||||
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
wildcard,
|
||||
...(domainId !== null && { domainId })
|
||||
})
|
||||
.where(eq(certificates.domain, domain));
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
|
||||
await pushCertUpdateToAffectedNewts(
|
||||
domain,
|
||||
domainId,
|
||||
oldCertPem,
|
||||
oldKeyPem
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
await db.insert(certificates).values({
|
||||
domain,
|
||||
domainId,
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
wildcard
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
|
||||
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
function findAcmeJsonFiles(dirPath: string): string[] {
|
||||
const results: string[] = [];
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`acmeCertSync: could not read directory "${dirPath}": ${err}`
|
||||
);
|
||||
return results;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...findAcmeJsonFiles(fullPath));
|
||||
} else if (entry.isFile()) {
|
||||
// check if it is a json file
|
||||
if (entry.name.endsWith(".json")) {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(fullPath, "utf8");
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`acmeCertSync: could not read file "${fullPath}": ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`acmeCertSync: could not parse "${fullPath}" as JSON: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||
} catch (err) {
|
||||
logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let acmeJson: AcmeJson;
|
||||
try {
|
||||
acmeJson = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvers = Object.keys(acmeJson || {});
|
||||
if (resolvers.length === 0) {
|
||||
logger.debug(`acmeCertSync: no resolvers found in acme.json`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect certificates from every resolver. If the same domain appears in
|
||||
// multiple resolvers, the last one wins (resolvers iterated in object order).
|
||||
const allCerts: AcmeCert[] = [];
|
||||
for (const resolver of resolvers) {
|
||||
const resolverData = acmeJson[resolver];
|
||||
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// logger.debug(
|
||||
// `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"`
|
||||
// );
|
||||
for (const cert of resolverData.Certificates) {
|
||||
allCerts.push(cert);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cert of allCerts) {
|
||||
const mainDomain = cert?.domain?.main;
|
||||
|
||||
if (!mainDomain || typeof mainDomain !== "string") {
|
||||
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!cert.certificate || !cert.key) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - empty certificate or key field`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let certPem: string;
|
||||
let keyPem: string;
|
||||
try {
|
||||
certPem = Buffer.from(cert.certificate, "base64").toString("utf8");
|
||||
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - failed to base64-decode cert/key: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!certPem.trim() || !keyPem.trim()) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - blank PEM after base64 decode`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate that the decoded data actually parses as a real X.509 cert
|
||||
// before we touch the database. This prevents importing partially-written
|
||||
// or corrupted entries from acme.json.
|
||||
const firstCertPemForValidation = extractFirstCert(certPem);
|
||||
if (!firstCertPemForValidation) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - no PEM certificate block found`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let validatedX509: crypto.X509Certificate;
|
||||
try {
|
||||
validatedX509 = new crypto.X509Certificate(
|
||||
firstCertPemForValidation
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - invalid X.509 certificate: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sanity-check the private key parses too
|
||||
try {
|
||||
crypto.createPrivateKey(keyPem);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - invalid private key: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect all domains covered by this cert: main + every SAN.
|
||||
// Each domain gets its own row in the certificates table so that
|
||||
// lookups by any hostname on the cert succeed independently.
|
||||
const allDomains = new Set<string>([mainDomain]);
|
||||
if (Array.isArray(cert.domain?.sans)) {
|
||||
for (const san of cert.domain.sans) {
|
||||
if (typeof san === "string" && san.trim()) {
|
||||
allDomains.add(san.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logger.debug(
|
||||
// `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
||||
// );
|
||||
|
||||
for (const domain of allDomains) {
|
||||
try {
|
||||
await storeCertForDomain(
|
||||
domain,
|
||||
certPem,
|
||||
keyPem,
|
||||
validatedX509
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`acmeCertSync: error storing cert for domain "${domain}": ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initAcmeCertSync(): void {
|
||||
if (build == "saas") {
|
||||
logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`);
|
||||
return;
|
||||
}
|
||||
|
||||
const privateConfigData = privateConfig.getRawPrivateConfig();
|
||||
|
||||
if (!privateConfigData.flags?.enable_acme_cert_sync) {
|
||||
logger.debug(
|
||||
`acmeCertSync: ACME cert sync is disabled by config flag, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (privateConfigData.flags.use_pangolin_dns) {
|
||||
logger.debug(
|
||||
`acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be disabled, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const acmeJsonPath =
|
||||
privateConfigData.acme?.acme_json_path ??
|
||||
"config/letsencrypt/acme.json";
|
||||
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
|
||||
const httpEndpoint = privateConfigData.acme?.acme_http_endpoint;
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms`
|
||||
);
|
||||
if (httpEndpoint) {
|
||||
logger.debug(
|
||||
`acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms`
|
||||
);
|
||||
}
|
||||
|
||||
const runSync = () => {
|
||||
if (httpEndpoint) {
|
||||
syncAcmeCertsFromHttp(httpEndpoint).catch((err) => {
|
||||
logger.error(`acmeCertSync: error during HTTP sync: ${err}`);
|
||||
});
|
||||
} else {
|
||||
// only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up
|
||||
let stat: fs.Stats | null = null;
|
||||
try {
|
||||
stat = fs.statSync(acmeJsonPath);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`acmeCertSync: cannot stat path "${acmeJsonPath}": ${err}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const files = findAcmeJsonFiles(acmeJsonPath);
|
||||
if (files.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no acme.json files found in directory "${acmeJsonPath}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// logger.debug(
|
||||
// `acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"`
|
||||
// );
|
||||
for (const file of files) {
|
||||
syncAcmeCerts(file).catch((err) => {
|
||||
logger.error(
|
||||
`acmeCertSync: error during sync of "${file}": ${err}`
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
syncAcmeCerts(acmeJsonPath).catch((err) => {
|
||||
logger.error(`acmeCertSync: error during sync: ${err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Run immediately on init, then on the configured interval
|
||||
runSync();
|
||||
|
||||
setInterval(runSync, intervalMs);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db, userOrgRoles, users } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import type {
|
||||
EmailAlertAction,
|
||||
TestAlertContext,
|
||||
WebhookAlertConfig
|
||||
} from "@server/routers/alertRule/types";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { sendAlertEmail } from "./sendAlertEmail";
|
||||
import { sendAlertWebhook } from "./sendAlertWebhook";
|
||||
|
||||
export async function processTestAlerts(context: TestAlertContext) {
|
||||
// Process email actions
|
||||
const emailActions = context.actions.filter(
|
||||
(action) => action.type === "email"
|
||||
);
|
||||
for (const action of emailActions) {
|
||||
try {
|
||||
const recipients = await resolveEmailRecipients(action);
|
||||
if (recipients.length > 0) {
|
||||
await sendAlertEmail(recipients, {
|
||||
...context,
|
||||
isTest: true
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`processTestAlerts: failed to send alert email`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Process webhook actions
|
||||
const webhookActions = context.actions.filter(
|
||||
(action) => action.type === "webhook"
|
||||
);
|
||||
|
||||
for (const action of webhookActions) {
|
||||
try {
|
||||
let webhookConfig: WebhookAlertConfig = { authType: "none" };
|
||||
|
||||
if (action.config) {
|
||||
try {
|
||||
webhookConfig = JSON.parse(
|
||||
action.config
|
||||
) as WebhookAlertConfig;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`processTestAlerts: failed to decrypt webhook`,
|
||||
err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await sendAlertWebhook(action.webhookUrl, webhookConfig, {
|
||||
...context,
|
||||
isTest: true
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`processTestAlerts: failed to send alert webhook `,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves all email addresses for a given `emailActionId`.
|
||||
*
|
||||
* Recipients may be:
|
||||
* - Direct users (by `userId`)
|
||||
* - All users in a role (by `roleId`, resolved via `userOrgRoles`)
|
||||
* - Direct external email addresses
|
||||
*/
|
||||
async function resolveEmailRecipients(
|
||||
action: EmailAlertAction
|
||||
): Promise<string[]> {
|
||||
const emailList: string[] = [];
|
||||
|
||||
emailList.push(...(action.emails ?? []));
|
||||
|
||||
if (action.userIds && action.userIds?.length > 0) {
|
||||
const userList = await db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(inArray(users.userId, action.userIds));
|
||||
|
||||
emailList.push(
|
||||
...userList.filter((u) => u.email !== null).map((u) => u.email!)
|
||||
);
|
||||
}
|
||||
if (action.roleIds && action.roleIds?.length > 0) {
|
||||
const userList = await db
|
||||
.select({ email: users.email })
|
||||
.from(userOrgRoles)
|
||||
.innerJoin(users, eq(userOrgRoles.userId, users.userId))
|
||||
.where(inArray(userOrgRoles.roleId, action.roleIds.map(Number)));
|
||||
|
||||
emailList.push(
|
||||
...userList.filter((u) => u.email !== null).map((u) => u.email!)
|
||||
);
|
||||
}
|
||||
|
||||
return [...new Set(emailList)];
|
||||
}
|
||||
@@ -15,7 +15,21 @@ import { sendEmail } from "@server/emails";
|
||||
import AlertNotification from "@server/emails/templates/AlertNotification";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import { AlertContext } from "@server/routers/alertRule/types";
|
||||
import { type AlertEventType } from "@server/routers/alertRule/types";
|
||||
|
||||
type EmailAlertContext = {
|
||||
eventType: AlertEventType;
|
||||
orgId: string;
|
||||
/** Set for site_online / site_offline events */
|
||||
siteId?: number;
|
||||
/** Set for health_check_* events */
|
||||
healthCheckId?: number;
|
||||
/** Set for resource_* events */
|
||||
resourceId?: number;
|
||||
/** Human-readable context data included in emails and webhook payloads */
|
||||
data: Record<string, unknown>;
|
||||
isTest?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends an alert notification email to every address in `recipients`.
|
||||
@@ -27,7 +41,7 @@ import { AlertContext } from "@server/routers/alertRule/types";
|
||||
*/
|
||||
export async function sendAlertEmail(
|
||||
recipients: string[],
|
||||
context: AlertContext
|
||||
context: EmailAlertContext
|
||||
): Promise<void> {
|
||||
if (recipients.length === 0) {
|
||||
return;
|
||||
@@ -46,7 +60,8 @@ export async function sendAlertEmail(
|
||||
eventType: context.eventType,
|
||||
orgId: context.orgId,
|
||||
data: context.data,
|
||||
dashboardLink
|
||||
dashboardLink,
|
||||
isTestAlert: context.isTest
|
||||
}),
|
||||
{
|
||||
from,
|
||||
@@ -70,34 +85,35 @@ export async function sendAlertEmail(
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildSubject(context: AlertContext): string {
|
||||
function buildSubject(context: EmailAlertContext): string {
|
||||
const prefix = context.isTest ? "[Test Alert]" : "[Alert]";
|
||||
switch (context.eventType) {
|
||||
case "site_online":
|
||||
return "[Alert] Site Back Online";
|
||||
return `${prefix} Site Back Online`;
|
||||
case "site_offline":
|
||||
return "[Alert] Site Offline";
|
||||
return `${prefix} Site Offline`;
|
||||
case "site_toggle":
|
||||
return "[Alert] Site Status Changed";
|
||||
return `${prefix} Site Status Changed`;
|
||||
case "health_check_healthy":
|
||||
return "[Alert] Health Check Recovered";
|
||||
return `${prefix} Health Check Recovered`;
|
||||
case "health_check_unhealthy":
|
||||
return "[Alert] Health Check Failing";
|
||||
return `${prefix} Health Check Failing`;
|
||||
case "health_check_toggle":
|
||||
return "[Alert] Health Check Status Changed";
|
||||
return `${prefix} Health Check Status Changed`;
|
||||
case "resource_healthy":
|
||||
return "[Alert] Resource Healthy";
|
||||
return `${prefix} Resource Healthy`;
|
||||
case "resource_unhealthy":
|
||||
return "[Alert] Resource Unhealthy";
|
||||
return `${prefix} Resource Unhealthy`;
|
||||
case "resource_degraded":
|
||||
return "[Alert] Resource Degraded";
|
||||
return `${prefix} Resource Degraded`;
|
||||
case "resource_toggle":
|
||||
return "[Alert] Resource Status Changed";
|
||||
return `${prefix} Resource Status Changed`;
|
||||
default: {
|
||||
// Exhaustiveness fallback – should never be reached with a
|
||||
// well-typed caller, but keeps runtime behaviour predictable.
|
||||
const _exhaustive: never = context.eventType;
|
||||
void _exhaustive;
|
||||
return "[Alert] Event Notification";
|
||||
return `${prefix} Event Notification`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,28 @@
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
AlertContext,
|
||||
WebhookAlertConfig
|
||||
WebhookAlertConfig,
|
||||
type AlertEventType
|
||||
} from "@server/routers/alertRule/types";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_BASE_DELAY_MS = 500;
|
||||
|
||||
type WebhookAlertContext = {
|
||||
eventType: AlertEventType;
|
||||
orgId: string;
|
||||
/** Set for site_online / site_offline events */
|
||||
siteId?: number;
|
||||
/** Set for health_check_* events */
|
||||
healthCheckId?: number;
|
||||
/** Set for resource_* events */
|
||||
resourceId?: number;
|
||||
/** Human-readable context data included in emails and webhook payloads */
|
||||
data: Record<string, unknown>;
|
||||
isTest?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a single webhook POST for an alert event.
|
||||
*
|
||||
@@ -40,14 +55,14 @@ const RETRY_BASE_DELAY_MS = 500;
|
||||
export async function sendAlertWebhook(
|
||||
url: string,
|
||||
webhookConfig: WebhookAlertConfig,
|
||||
context: AlertContext
|
||||
context: WebhookAlertContext
|
||||
): Promise<void> {
|
||||
const eventType = context.eventType;
|
||||
const timestamp = new Date().toISOString();
|
||||
const status = deriveStatus(eventType, context.data);
|
||||
const data = { orgId: context.orgId, ...context.data };
|
||||
|
||||
let body: string;
|
||||
let body: Record<string, any>;
|
||||
if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) {
|
||||
body = renderTemplate(webhookConfig.bodyTemplate, {
|
||||
event: eventType,
|
||||
@@ -56,7 +71,11 @@ export async function sendAlertWebhook(
|
||||
data
|
||||
});
|
||||
} else {
|
||||
body = JSON.stringify({ event: eventType, timestamp, status, data });
|
||||
body = { event: eventType, timestamp, status, data };
|
||||
}
|
||||
|
||||
if (body.data && context.isTest) {
|
||||
body.data.test = true;
|
||||
}
|
||||
|
||||
const headers = buildHeaders(webhookConfig);
|
||||
@@ -75,7 +94,7 @@ export async function sendAlertWebhook(
|
||||
response = await fetch(url, {
|
||||
method: webhookConfig.method ?? "POST",
|
||||
headers,
|
||||
body,
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
@@ -247,7 +266,10 @@ interface TemplateContext {
|
||||
* left untouched.
|
||||
* 3. The fixed top-level keys: event, timestamp, status.
|
||||
*/
|
||||
function renderTemplate(template: string, ctx: TemplateContext): string {
|
||||
function renderTemplate(
|
||||
template: string,
|
||||
ctx: TemplateContext
|
||||
): Record<string, any> {
|
||||
// Step 1 – expand {{data}} first so its contents are already serialised
|
||||
// and won't be touched by later passes.
|
||||
let rendered = template.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data));
|
||||
@@ -280,20 +302,19 @@ function renderTemplate(template: string, ctx: TemplateContext): string {
|
||||
// Validate the rendered result is valid JSON; if not, log a warning and
|
||||
// fall back to the default payload so the webhook still fires.
|
||||
try {
|
||||
JSON.parse(rendered);
|
||||
return rendered;
|
||||
return JSON.parse(rendered);
|
||||
} catch {
|
||||
logger.warn(
|
||||
`sendAlertWebhook: body template produced invalid JSON for event ` +
|
||||
`"${ctx.event}" destined for a webhook. Falling back to default ` +
|
||||
`payload. Check that {{data}} is NOT wrapped in quotes in your template.`
|
||||
);
|
||||
return JSON.stringify({
|
||||
return {
|
||||
event: ctx.event,
|
||||
timestamp: ctx.timestamp,
|
||||
status: ctx.status,
|
||||
data: ctx.data
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-20
@@ -24,12 +24,12 @@ export const localCache = new NodeCache({
|
||||
});
|
||||
|
||||
// Log cache statistics periodically for monitoring
|
||||
setInterval(() => {
|
||||
const stats = localCache.getStats();
|
||||
logger.debug(
|
||||
`Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
|
||||
);
|
||||
}, 300000); // Every 5 minutes
|
||||
// setInterval(() => {
|
||||
// const stats = localCache.getStats();
|
||||
// logger.debug(
|
||||
// `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
|
||||
// );
|
||||
// }, 300000); // Every 5 minutes
|
||||
|
||||
/**
|
||||
* Adaptive cache that uses Redis when available in multi-node environments,
|
||||
@@ -80,9 +80,9 @@ class AdaptiveCache {
|
||||
|
||||
// Use local cache as fallback or primary
|
||||
const success = localCache.set(key, value, effectiveTtl || 0);
|
||||
if (success) {
|
||||
logger.debug(`Set key in local cache: ${key}`);
|
||||
}
|
||||
// if (success) {
|
||||
// logger.debug(`Set key in local cache: ${key}`);
|
||||
// }
|
||||
return success;
|
||||
}
|
||||
|
||||
@@ -111,11 +111,11 @@ class AdaptiveCache {
|
||||
|
||||
// Use local cache as fallback or primary
|
||||
const value = localCache.get<T>(key);
|
||||
if (value !== undefined) {
|
||||
logger.debug(`Cache hit in local cache: ${key}`);
|
||||
} else {
|
||||
logger.debug(`Cache miss in local cache: ${key}`);
|
||||
}
|
||||
// if (value !== undefined) {
|
||||
// logger.debug(`Cache hit in local cache: ${key}`);
|
||||
// } else {
|
||||
// logger.debug(`Cache miss in local cache: ${key}`);
|
||||
// }
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@ class RegionalAdaptiveCache {
|
||||
}
|
||||
|
||||
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
|
||||
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
|
||||
// if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
|
||||
return success;
|
||||
}
|
||||
|
||||
@@ -367,11 +367,11 @@ class RegionalAdaptiveCache {
|
||||
}
|
||||
|
||||
const value = regionalLocalCache.get<T>(key);
|
||||
if (value !== undefined) {
|
||||
logger.debug(`[regional] Cache hit in local cache: ${key}`);
|
||||
} else {
|
||||
logger.debug(`[regional] Cache miss in local cache: ${key}`);
|
||||
}
|
||||
// if (value !== undefined) {
|
||||
// logger.debug(`[regional] Cache hit in local cache: ${key}`);
|
||||
// } else {
|
||||
// logger.debug(`[regional] Cache miss in local cache: ${key}`);
|
||||
// }
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import privateConfig from "./config";
|
||||
import config from "@server/lib/config";
|
||||
import { certificates, db } from "@server/db";
|
||||
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import { regionalCache as cache } from "#private/lib/cache";
|
||||
import { build } from "@server/build";
|
||||
|
||||
// Define the return type for clarity and type safety
|
||||
export type CertificateResult = {
|
||||
id: number;
|
||||
domain: string;
|
||||
queriedDomain: string; // The domain that was originally requested (may differ for wildcards)
|
||||
wildcard: boolean | null;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: number | null;
|
||||
updatedAt?: number | null;
|
||||
};
|
||||
|
||||
export async function getValidCertificatesForDomains(
|
||||
domains: Set<string>,
|
||||
useCache: boolean = true
|
||||
): Promise<Array<CertificateResult>> {
|
||||
const finalResults: CertificateResult[] = [];
|
||||
const domainsToQuery = new Set<string>();
|
||||
|
||||
// 1. Check cache first if enabled
|
||||
if (useCache) {
|
||||
for (const domain of domains) {
|
||||
const cacheKey = `cert:${domain}`;
|
||||
const cachedCert = await cache.get<CertificateResult>(cacheKey);
|
||||
if (cachedCert) {
|
||||
finalResults.push(cachedCert); // Valid cache hit
|
||||
} else {
|
||||
// Also check for a wildcard cache entry covering this domain's parent
|
||||
const parts = domain.split(".");
|
||||
let wildcardHit = false;
|
||||
if (parts.length > 1) {
|
||||
const parentDomain = parts.slice(1).join(".");
|
||||
const wildcardCacheKey = `cert:*.${parentDomain}`;
|
||||
const cachedWildcard =
|
||||
await cache.get<CertificateResult>(wildcardCacheKey);
|
||||
if (cachedWildcard) {
|
||||
// Re-stamp queriedDomain so callers see the originally requested domain
|
||||
finalResults.push({
|
||||
...cachedWildcard,
|
||||
queriedDomain: domain
|
||||
});
|
||||
wildcardHit = true;
|
||||
}
|
||||
}
|
||||
if (!wildcardHit) {
|
||||
domainsToQuery.add(domain); // Cache miss or expired
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If caching is disabled, add all domains to the query set
|
||||
domains.forEach((d) => domainsToQuery.add(d));
|
||||
}
|
||||
|
||||
// 2. If all domains were resolved from the cache, return early
|
||||
if (domainsToQuery.size === 0) {
|
||||
const decryptedResults = decryptFinalResults(
|
||||
finalResults,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
// 3. Prepare domains for the database query
|
||||
const domainsToQueryArray = Array.from(domainsToQuery);
|
||||
const parentDomainsToQuery = new Set<string>();
|
||||
|
||||
domainsToQueryArray.forEach((domain) => {
|
||||
const parts = domain.split(".");
|
||||
// A wildcard can only match a domain with at least two parts (e.g., example.com)
|
||||
if (parts.length > 1) {
|
||||
parentDomainsToQuery.add(parts.slice(1).join("."));
|
||||
}
|
||||
});
|
||||
|
||||
const parentDomainsArray = Array.from(parentDomainsToQuery);
|
||||
|
||||
// Build wildcard variants: for each parent domain "example.com", also query "*.example.com"
|
||||
const wildcardPrefixedArray =
|
||||
build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
|
||||
|
||||
// 4. Build and execute a single, efficient Drizzle query
|
||||
// This query fetches all potential exact and wildcard matches in one database round-trip.
|
||||
const potentialCerts = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.status, "valid"),
|
||||
isNotNull(certificates.certFile),
|
||||
isNotNull(certificates.keyFile),
|
||||
or(
|
||||
// Condition for exact matches on the requested domains
|
||||
inArray(certificates.domain, domainsToQueryArray),
|
||||
// Condition for wildcard matches on the parent domains (stored as "example.com" or "*.example.com")
|
||||
parentDomainsArray.length > 0
|
||||
? and(
|
||||
inArray(certificates.domain, [
|
||||
...parentDomainsArray,
|
||||
...wildcardPrefixedArray
|
||||
]),
|
||||
eq(certificates.wildcard, true)
|
||||
)
|
||||
: // If there are no possible parent domains, this condition is false
|
||||
sql`false`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.")
|
||||
const normalizeWildcardDomain = (domain: string): string =>
|
||||
domain.startsWith("*.") ? domain.slice(2) : domain;
|
||||
|
||||
// 5. Process the database results, prioritizing exact matches over wildcards
|
||||
const exactMatches = new Map<string, (typeof potentialCerts)[0]>();
|
||||
const wildcardMatches = new Map<string, (typeof potentialCerts)[0]>();
|
||||
|
||||
for (const cert of potentialCerts) {
|
||||
if (cert.wildcard) {
|
||||
// Normalize to bare parent domain so lookups are consistent regardless of storage format
|
||||
wildcardMatches.set(normalizeWildcardDomain(cert.domain), cert);
|
||||
} else {
|
||||
exactMatches.set(cert.domain, cert);
|
||||
}
|
||||
}
|
||||
|
||||
for (const domain of domainsToQuery) {
|
||||
let foundCert: (typeof potentialCerts)[0] | undefined = undefined;
|
||||
|
||||
// Priority 1: Check for an exact match (non-wildcard)
|
||||
if (exactMatches.has(domain)) {
|
||||
foundCert = exactMatches.get(domain);
|
||||
}
|
||||
// Priority 2: Check for a wildcard certificate whose normalized domain equals the queried domain
|
||||
else {
|
||||
const normalizedDomain = normalizeWildcardDomain(domain);
|
||||
if (wildcardMatches.has(normalizedDomain)) {
|
||||
foundCert = wildcardMatches.get(normalizedDomain);
|
||||
}
|
||||
// Priority 3: Check for a wildcard match on the parent domain
|
||||
else {
|
||||
const parts = normalizedDomain.split(".");
|
||||
if (parts.length > 1) {
|
||||
const parentDomain = parts.slice(1).join(".");
|
||||
if (wildcardMatches.has(parentDomain)) {
|
||||
foundCert = wildcardMatches.get(parentDomain);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If a certificate was found, format it, add to results, and cache it
|
||||
if (foundCert) {
|
||||
logger.debug(
|
||||
`Creating result cert for ${domain} using cert from ${foundCert.domain}`
|
||||
);
|
||||
const resultCert: CertificateResult = {
|
||||
id: foundCert.certId,
|
||||
domain: foundCert.domain, // The actual domain of the cert record
|
||||
queriedDomain: domain, // The domain that was originally requested
|
||||
wildcard: foundCert.wildcard,
|
||||
certFile: foundCert.certFile,
|
||||
keyFile: foundCert.keyFile,
|
||||
expiresAt: foundCert.expiresAt,
|
||||
updatedAt: foundCert.updatedAt
|
||||
};
|
||||
|
||||
finalResults.push(resultCert);
|
||||
|
||||
// Add to cache for future requests, using the *requested domain* as the key
|
||||
if (useCache) {
|
||||
const cacheKey = `cert:${domain}`;
|
||||
await cache.set(cacheKey, resultCert, 180);
|
||||
|
||||
// Also cache wildcard certs under a pattern key so other subdomains
|
||||
// can find them without a DB round-trip
|
||||
if (resultCert.wildcard) {
|
||||
const normalizedCertDomain = normalizeWildcardDomain(
|
||||
resultCert.domain
|
||||
);
|
||||
const wildcardCacheKey = `cert:*.${normalizedCertDomain}`;
|
||||
await cache.set(wildcardCacheKey, resultCert, 180);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const decryptedResults = decryptFinalResults(
|
||||
finalResults,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
function decryptFinalResults(
|
||||
finalResults: CertificateResult[],
|
||||
secret: string
|
||||
): CertificateResult[] {
|
||||
const validCertsDecrypted = finalResults.map((cert) => {
|
||||
// Decrypt and save certificate file
|
||||
const decryptedCert = decrypt(
|
||||
cert.certFile!, // is not null from query
|
||||
secret
|
||||
);
|
||||
|
||||
// Decrypt and save key file
|
||||
const decryptedKey = decrypt(cert.keyFile!, secret);
|
||||
|
||||
// Return only the certificate data without org information
|
||||
return {
|
||||
...cert,
|
||||
certFile: decryptedCert,
|
||||
keyFile: decryptedKey
|
||||
};
|
||||
});
|
||||
|
||||
return validCertsDecrypted;
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
privateConfigSchema,
|
||||
readPrivateConfigFile
|
||||
} from "#private/lib/readConfigFile";
|
||||
import config from "@server/lib/config";
|
||||
import { readConfigFile as readPublicConfigFile } from "@server/lib/readConfigFile";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export class PrivateConfig {
|
||||
private rawPrivateConfig!: z.infer<typeof privateConfigSchema>;
|
||||
@@ -45,6 +48,8 @@ export class PrivateConfig {
|
||||
|
||||
this.rawPrivateConfig = parsedPrivateConfig;
|
||||
|
||||
this.migrateDeprecatedConfig(privateEnvironment);
|
||||
|
||||
process.env.BRANDING_HIDE_AUTH_LAYOUT_FOOTER =
|
||||
this.rawPrivateConfig.branding?.hide_auth_layout_footer === true
|
||||
? "true"
|
||||
@@ -146,6 +151,52 @@ export class PrivateConfig {
|
||||
public getRawPrivateConfig() {
|
||||
return this.rawPrivateConfig;
|
||||
}
|
||||
|
||||
// `flags.enable_acme_cert_sync`, `flags.disable_private_http_placeholder`,
|
||||
// and `acme` used to live in the private config file. They now live in
|
||||
// the public config file. If an operator still has them set in the
|
||||
// private config and hasn't moved them over to the public config, pull
|
||||
// them forward so behavior doesn't silently change out from under them.
|
||||
private migrateDeprecatedConfig(privateEnvironment: any) {
|
||||
const publicEnvironment: any = readPublicConfigFile();
|
||||
const rawConfig: any = config.getRawConfig();
|
||||
|
||||
if (
|
||||
privateEnvironment?.flags?.enable_acme_cert_sync !== undefined &&
|
||||
publicEnvironment?.flags?.enable_acme_cert_sync === undefined
|
||||
) {
|
||||
logger.warn(
|
||||
"`flags.enable_acme_cert_sync` is deprecated in the private config file and has moved to the public config file. Using the value from the private config file for now, but please move it to the public config."
|
||||
);
|
||||
rawConfig.flags = rawConfig.flags ?? {};
|
||||
rawConfig.flags.enable_acme_cert_sync =
|
||||
this.rawPrivateConfig.flags.enable_acme_cert_sync;
|
||||
}
|
||||
|
||||
if (
|
||||
privateEnvironment?.acme !== undefined &&
|
||||
publicEnvironment?.acme === undefined
|
||||
) {
|
||||
logger.warn(
|
||||
"`acme` is deprecated in the private config file and has moved to the public config file. Using the value from the private config file for now, but please move it to the public config."
|
||||
);
|
||||
rawConfig.acme = this.rawPrivateConfig.acme;
|
||||
}
|
||||
|
||||
if (
|
||||
privateEnvironment?.flags?.disable_private_http_placeholder !==
|
||||
undefined &&
|
||||
publicEnvironment?.flags?.disable_private_http_placeholder ===
|
||||
undefined
|
||||
) {
|
||||
logger.warn(
|
||||
"`flags.disable_private_http_placeholder` is deprecated in the private config file and has moved to the public config file. Using the value from the private config file for now, but please move it to the public config."
|
||||
);
|
||||
rawConfig.flags = rawConfig.flags ?? {};
|
||||
rawConfig.flags.disable_private_http_placeholder =
|
||||
this.rawPrivateConfig.flags.disable_private_http_placeholder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const privateConfig = new PrivateConfig();
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
Transaction
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { ExitNodePingResult } from "@server/routers/newt";
|
||||
import { eq, and, or, ne, isNull, inArray } from "drizzle-orm";
|
||||
import axios from "axios";
|
||||
import config from "../config";
|
||||
@@ -154,7 +153,8 @@ export async function listExitNodes(
|
||||
orgId: string,
|
||||
filterOnline = false,
|
||||
noCloud = false,
|
||||
siteId?: number
|
||||
siteId?: number,
|
||||
noRemote = false
|
||||
) {
|
||||
const allExitNodes = await db
|
||||
.select({
|
||||
@@ -243,7 +243,9 @@ export async function listExitNodes(
|
||||
|
||||
let remoteExitNodesList = allExitNodes.filter(
|
||||
(node) =>
|
||||
node.type === "remoteExitNode" && (!filterOnline || node.online)
|
||||
node.type === "remoteExitNode" &&
|
||||
!noRemote &&
|
||||
(!filterOnline || node.online)
|
||||
);
|
||||
const gerbilExitNodes = allExitNodes.filter(
|
||||
(node) =>
|
||||
@@ -330,6 +332,16 @@ export async function listExitNodes(
|
||||
return exitNodesList;
|
||||
}
|
||||
|
||||
export type ExitNodePingResult = {
|
||||
exitNodeId: number;
|
||||
latencyMs: number;
|
||||
weight: number;
|
||||
error?: string;
|
||||
exitNodeName: string;
|
||||
endpoint: string;
|
||||
wasPreviouslyConnected: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Selects the most suitable exit node from a list of ping results.
|
||||
*
|
||||
|
||||
@@ -28,7 +28,7 @@ async function getAccessDays(orgId: string): Promise<number> {
|
||||
|
||||
const [org] = await db
|
||||
.select({
|
||||
settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction
|
||||
settingsLogRetentionDaysAccess: orgs.settingsLogRetentionDaysAccess
|
||||
})
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
@@ -41,11 +41,11 @@ async function getAccessDays(orgId: string): Promise<number> {
|
||||
// store the result in cache
|
||||
await cache.set(
|
||||
`org_${orgId}_accessDays`,
|
||||
org.settingsLogRetentionDaysAction,
|
||||
org.settingsLogRetentionDaysAccess,
|
||||
300
|
||||
);
|
||||
|
||||
return org.settingsLogRetentionDaysAction;
|
||||
return org.settingsLogRetentionDaysAccess;
|
||||
}
|
||||
|
||||
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
||||
|
||||
@@ -109,22 +109,31 @@ export const privateConfigSchema = z
|
||||
enable_redis: z.boolean().optional().default(false),
|
||||
use_pangolin_dns: z.boolean().optional().default(false),
|
||||
use_org_only_idp: z.boolean().optional(),
|
||||
enable_acme_cert_sync: z.boolean().optional().default(true),
|
||||
disable_private_http_placeholder: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
// @deprecated Moved to the public config file as
|
||||
// `flags.enable_acme_cert_sync` (server/lib/readConfigFile.ts).
|
||||
// Kept here only so existing private config files keep parsing;
|
||||
// any value set here is migrated into the public config at
|
||||
// startup by PrivateConfig (server/private/lib/config.ts).
|
||||
enable_acme_cert_sync: z.boolean().optional(),
|
||||
// @deprecated Moved to the public config file as
|
||||
// `flags.disable_private_http_placeholder`
|
||||
// (server/lib/readConfigFile.ts). Kept here only so existing
|
||||
// private config files keep parsing; any value set here is
|
||||
// migrated into the public config at startup by PrivateConfig
|
||||
// (server/private/lib/config.ts).
|
||||
disable_private_http_placeholder: z.boolean().optional()
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
// @deprecated Moved to the public config file as `acme`
|
||||
// (server/lib/readConfigFile.ts). Kept here only so existing private
|
||||
// config files keep parsing; any value set here is migrated into the
|
||||
// public config at startup by PrivateConfig (server/private/lib/config.ts).
|
||||
acme: z
|
||||
.object({
|
||||
acme_json_path: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("config/letsencrypt/acme.json"),
|
||||
acme_json_path: z.string().optional(),
|
||||
acme_http_endpoint: z.string().optional(),
|
||||
sync_interval_ms: z.number().optional().default(5000)
|
||||
sync_interval_ms: z.number().optional()
|
||||
})
|
||||
.optional(),
|
||||
branding: z
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,11 +104,18 @@ LQIDAQAB
|
||||
}
|
||||
|
||||
public async forceRecheck() {
|
||||
this.statusCache.flushAll();
|
||||
this.licenseKeyCache.flushAll();
|
||||
this.phoneHomeFailureCount = 0;
|
||||
|
||||
return await this.check();
|
||||
// Force a fresh check without discarding the last known good cache
|
||||
// up front — check() only replaces the cache once it has a fresh
|
||||
// result, so a failed recheck (e.g. a transient server error) won't
|
||||
// leave listKeys()/status looking empty in the meantime.
|
||||
this.doRecheck = true;
|
||||
try {
|
||||
return await this.check();
|
||||
} finally {
|
||||
this.doRecheck = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async isUnlocked(): Promise<boolean> {
|
||||
@@ -181,10 +188,15 @@ LQIDAQAB
|
||||
}
|
||||
|
||||
let foundHostKey = false;
|
||||
// Keys that fully decrypted, to phone home with. A row that
|
||||
// fails to decrypt (e.g. stored under a different server
|
||||
// secret) is marked invalid below but excluded here, so it
|
||||
// can't take down validation for every other key in the batch.
|
||||
const keys: { licenseKey: string; instanceId: string }[] = [];
|
||||
// Validate stored license keys
|
||||
for (const key of allKeysRes) {
|
||||
try {
|
||||
// Decrypt the license key and token
|
||||
// Decrypt the license key, token, and instance ID
|
||||
const decryptedKey = decrypt(
|
||||
key.licenseKeyId,
|
||||
this.serverSecret
|
||||
@@ -193,6 +205,10 @@ LQIDAQAB
|
||||
key.token,
|
||||
this.serverSecret
|
||||
);
|
||||
const decryptedInstanceId = decrypt(
|
||||
key.instanceId,
|
||||
this.serverSecret
|
||||
);
|
||||
|
||||
const payload = validateJWT<TokenPayload>(
|
||||
decryptedToken,
|
||||
@@ -214,6 +230,11 @@ LQIDAQAB
|
||||
if (payload.type === "host") {
|
||||
foundHostKey = true;
|
||||
}
|
||||
|
||||
keys.push({
|
||||
licenseKey: decryptedKey,
|
||||
instanceId: decryptedInstanceId
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Error validating license key: ${key.licenseKeyId}`
|
||||
@@ -233,37 +254,36 @@ LQIDAQAB
|
||||
status.isHostLicensed = false;
|
||||
}
|
||||
|
||||
const keys = allKeysRes.map((key) => ({
|
||||
licenseKey: decrypt(key.licenseKeyId, this.serverSecret),
|
||||
instanceId: decrypt(key.instanceId, this.serverSecret)
|
||||
}));
|
||||
|
||||
let apiResponse: ValidateLicenseAPIResponse | undefined;
|
||||
try {
|
||||
// Phone home to validate license keys
|
||||
apiResponse = await this.phoneHome(keys, false);
|
||||
if (keys.length > 0) {
|
||||
try {
|
||||
// Phone home to validate license keys
|
||||
apiResponse = await this.phoneHome(keys, false);
|
||||
|
||||
if (!apiResponse?.success) {
|
||||
throw new Error(apiResponse?.error);
|
||||
}
|
||||
// Reset failure count on success
|
||||
this.phoneHomeFailureCount = 0;
|
||||
} catch (e) {
|
||||
this.phoneHomeFailureCount++;
|
||||
if (this.phoneHomeFailureCount === 1) {
|
||||
// First failure: fail silently
|
||||
logger.error("Error communicating with license server:");
|
||||
logger.error(e);
|
||||
logger.error(
|
||||
`Allowing failure. Will retry one more time at next run interval.`
|
||||
);
|
||||
// return last known good status
|
||||
return this.statusCache.get(
|
||||
this.statusKey
|
||||
) as LicenseStatus;
|
||||
} else {
|
||||
// Subsequent failures: fail abruptly
|
||||
throw e;
|
||||
if (!apiResponse?.success) {
|
||||
throw new Error(apiResponse?.error);
|
||||
}
|
||||
// Reset failure count on success
|
||||
this.phoneHomeFailureCount = 0;
|
||||
} catch (e) {
|
||||
this.phoneHomeFailureCount++;
|
||||
if (this.phoneHomeFailureCount === 1) {
|
||||
// First failure: fail silently
|
||||
logger.error(
|
||||
"Error communicating with license server:"
|
||||
);
|
||||
logger.error(e);
|
||||
logger.error(
|
||||
`Allowing failure. Will retry one more time at next run interval.`
|
||||
);
|
||||
// return last known good status
|
||||
return this.statusCache.get(
|
||||
this.statusKey
|
||||
) as LicenseStatus;
|
||||
} else {
|
||||
// Subsequent failures: fail abruptly
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./verifyCertificateAccess";
|
||||
export * from "./verifyRemoteExitNodeAccess";
|
||||
export * from "./verifyIdpAccess";
|
||||
export * from "./verifyLoginPageAccess";
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db, domainNamespaces } from "@server/db";
|
||||
import { certificates } from "@server/db";
|
||||
import { domains, orgDomains } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyCertificateAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
// Assume user/org access is already verified
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
|
||||
const certIdFromParams = getFirstString(req.params?.certId);
|
||||
const certIdFromBody = getFirstString(req.body?.certId);
|
||||
|
||||
if (
|
||||
certIdFromParams &&
|
||||
certIdFromBody &&
|
||||
certIdFromParams !== certIdFromBody
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Certificate ID provided in both URL and body with different values"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const certId = certIdFromParams || certIdFromBody;
|
||||
|
||||
const domainIdFromParams = getFirstString(req.params?.domainId);
|
||||
const domainIdFromBody = getFirstString(req.body?.domainId);
|
||||
|
||||
if (
|
||||
domainIdFromParams &&
|
||||
domainIdFromBody &&
|
||||
domainIdFromParams !== domainIdFromBody
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Domain ID provided in both URL and body with different values"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let domainId = domainIdFromParams || domainIdFromBody;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
if (!domainId) {
|
||||
if (!certId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Must provide either certId or domainId"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get the certificate and its domainId
|
||||
const [cert] = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.certId, Number(certId)))
|
||||
.limit(1);
|
||||
|
||||
if (!cert) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Certificate with ID ${certId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
domainId = cert.domainId ?? undefined;
|
||||
if (!domainId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Certificate with ID ${certId} does not have a domain`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!domainId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Must provide either certId or domainId"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the domain is a namespace domain
|
||||
const [namespaceDomain] = await db
|
||||
.select()
|
||||
.from(domainNamespaces)
|
||||
.where(eq(domainNamespaces.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (namespaceDomain) {
|
||||
// If it's a namespace domain, we can skip the org check
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check if the domain is associated with the org
|
||||
const [orgDomain] = await db
|
||||
.select()
|
||||
.from(orgDomains)
|
||||
.where(
|
||||
and(
|
||||
eq(orgDomains.orgId, orgId),
|
||||
eq(orgDomains.domainId, domainId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!orgDomain) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Organization does not have access to this certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying certificate access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
export default verifyCertificateAccess;
|
||||
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db";
|
||||
import type { InferInsertModel } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import cache from "#private/lib/cache";
|
||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||
import { sanitizeString } from "@server/lib/sanitize";
|
||||
import type { AiCapability } from "@server/lib/aiCapabilities";
|
||||
import {
|
||||
normalizeAiRequest,
|
||||
normalizeAiResponse
|
||||
} from "@server/lib/aiMessageNormalization";
|
||||
|
||||
// Caps how much of the request/response body we keep per row, so a single
|
||||
// huge multimodal payload can't blow up buffer memory or storage.
|
||||
const AI_SESSION_LOG_MAX_BODY_CHARS = 200_000;
|
||||
|
||||
type AiSessionLogInsert = InferInsertModel<typeof aiSessionLog>;
|
||||
|
||||
// In-memory buffer for batching AI session log inserts, mirroring the
|
||||
// approach in server/routers/badger/logRequestAudit.ts.
|
||||
const sessionLogBuffer: AiSessionLogInsert[] = [];
|
||||
|
||||
const BATCH_SIZE = 100; // Write to DB every 100 logs
|
||||
const BATCH_INTERVAL_MS = 5000; // Or every 5 seconds, whichever comes first
|
||||
const MAX_BUFFER_SIZE = 10000; // Prevent unbounded memory growth
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
let isFlushInProgress = false;
|
||||
|
||||
/**
|
||||
* Flush buffered logs to database
|
||||
*/
|
||||
async function flushSessionLogs() {
|
||||
if (sessionLogBuffer.length === 0 || isFlushInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
isFlushInProgress = true;
|
||||
|
||||
// Take all current logs and clear buffer
|
||||
const logsToWrite = sessionLogBuffer.splice(0, sessionLogBuffer.length);
|
||||
|
||||
try {
|
||||
// Use a transaction to ensure all inserts succeed or fail together
|
||||
await logsDb.transaction(async (tx) => {
|
||||
// Batch insert logs in groups of 25 to avoid overwhelming the database
|
||||
const BATCH_DB_SIZE = 25;
|
||||
for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) {
|
||||
const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE);
|
||||
await tx.insert(aiSessionLog).values(batch);
|
||||
}
|
||||
});
|
||||
logger.debug(
|
||||
`Flushed ${logsToWrite.length} AI session logs to database`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error flushing AI session logs:", error);
|
||||
// On transaction error, put logs back at the front of the buffer to retry
|
||||
// but only if buffer isn't too large
|
||||
if (sessionLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) {
|
||||
sessionLogBuffer.unshift(...logsToWrite);
|
||||
logger.info(
|
||||
`Re-queued ${logsToWrite.length} AI session logs for retry`
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`Buffer full, dropped ${logsToWrite.length} AI session logs`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isFlushInProgress = false;
|
||||
// If buffer filled up while we were flushing, flush again
|
||||
if (sessionLogBuffer.length >= BATCH_SIZE) {
|
||||
flushSessionLogs().catch((err) =>
|
||||
logger.error("Error in follow-up AI session log flush:", err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a flush if not already scheduled
|
||||
*/
|
||||
function scheduleFlush() {
|
||||
if (flushTimer === null) {
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushSessionLogs().catch((err) =>
|
||||
logger.error("Error in scheduled AI session log flush:", err)
|
||||
);
|
||||
}, BATCH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully flush all pending logs (call this on shutdown)
|
||||
*/
|
||||
export async function shutdownAiSessionLogger() {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
// Force flush even if one is in progress by waiting and retrying
|
||||
while (isFlushInProgress) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
await flushSessionLogs();
|
||||
}
|
||||
|
||||
async function getRetentionDays(orgId: string): Promise<number> {
|
||||
// check cache first
|
||||
const cached = await cache.get<number>(`org_${orgId}_aiSessionsDays`);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({
|
||||
settingsLogRetentionDaysAISessions:
|
||||
orgs.settingsLogRetentionDaysAISessions
|
||||
})
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// store the result in cache
|
||||
await cache.set(
|
||||
`org_${orgId}_aiSessionsDays`,
|
||||
org.settingsLogRetentionDaysAISessions,
|
||||
300
|
||||
);
|
||||
|
||||
return org.settingsLogRetentionDaysAISessions;
|
||||
}
|
||||
|
||||
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
||||
// calculateCutoffTimestamp returns a seconds-epoch cutoff (built for
|
||||
// requestAuditLog.timestamp), but aiSessionLog.createdAt is ms-epoch to
|
||||
// match aiUsageRecords - convert before comparing.
|
||||
const cutoffTimestampMs = calculateCutoffTimestamp(retentionDays) * 1000;
|
||||
|
||||
try {
|
||||
await logsDb
|
||||
.delete(aiSessionLog)
|
||||
.where(
|
||||
and(
|
||||
lt(aiSessionLog.createdAt, cutoffTimestampMs),
|
||||
eq(aiSessionLog.orgId, orgId)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error cleaning up old AI session logs:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function truncateBody(value: string): { value: string; truncated: boolean } {
|
||||
if (value.length <= AI_SESSION_LOG_MAX_BODY_CHARS) {
|
||||
return { value, truncated: false };
|
||||
}
|
||||
return {
|
||||
value: value.slice(0, AI_SESSION_LOG_MAX_BODY_CHARS),
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
|
||||
export function logAiSession(data: {
|
||||
sessionId: string;
|
||||
capability: AiCapability;
|
||||
provider: AiProvider;
|
||||
requestedModel: string | undefined;
|
||||
requestBody: unknown;
|
||||
responseText: string;
|
||||
isStream: boolean;
|
||||
statusCode: number;
|
||||
orgId: string | null;
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
requestUserId: string | null;
|
||||
virtualApiKeyId: string | null;
|
||||
}): void {
|
||||
(async () => {
|
||||
try {
|
||||
// Check retention before buffering any logs
|
||||
if (data.orgId) {
|
||||
const retentionDays = await getRetentionDays(data.orgId);
|
||||
if (retentionDays === 0) {
|
||||
// do not log
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No org resolved for this request - nothing to govern
|
||||
// retention with, so don't log it.
|
||||
return;
|
||||
}
|
||||
|
||||
const requestBodyText = truncateBody(
|
||||
JSON.stringify(data.requestBody ?? "")
|
||||
);
|
||||
const responseBodyText = truncateBody(data.responseText ?? "");
|
||||
|
||||
// Uniform, capability-agnostic transcript for search/display -
|
||||
// computed from the untruncated originals so normalization sees
|
||||
// the full content; the normalized result gets its own
|
||||
// (typically much smaller) truncation pass below.
|
||||
const normalizedRequestMessages = normalizeAiRequest(
|
||||
data.capability,
|
||||
data.requestBody
|
||||
);
|
||||
const normalizedResponseMessages = normalizeAiResponse(
|
||||
data.capability,
|
||||
data.responseText ?? "",
|
||||
data.isStream
|
||||
);
|
||||
const normalizedRequestText = normalizedRequestMessages
|
||||
? truncateBody(JSON.stringify(normalizedRequestMessages))
|
||||
: null;
|
||||
const normalizedResponseText = normalizedResponseMessages
|
||||
? truncateBody(JSON.stringify(normalizedResponseMessages))
|
||||
: null;
|
||||
|
||||
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
|
||||
if (sessionLogBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
const dropped = sessionLogBuffer.splice(0, BATCH_SIZE);
|
||||
logger.warn(
|
||||
`AI session log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries`
|
||||
);
|
||||
}
|
||||
|
||||
sessionLogBuffer.push({
|
||||
sessionId: data.sessionId,
|
||||
orgId: sanitizeString(data.orgId),
|
||||
providerId: data.provider.providerId,
|
||||
capability: data.capability,
|
||||
resourceId: data.resourceId ?? undefined,
|
||||
siteResourceId: data.siteResourceId ?? undefined,
|
||||
userId: sanitizeString(data.requestUserId ?? undefined),
|
||||
virtualApiKeyId: sanitizeString(
|
||||
data.virtualApiKeyId ?? undefined
|
||||
),
|
||||
requestedModel: sanitizeString(data.requestedModel),
|
||||
isStream: data.isStream,
|
||||
requestBody: sanitizeString(requestBodyText.value),
|
||||
responseBody: sanitizeString(responseBodyText.value),
|
||||
normalizedRequest: normalizedRequestText
|
||||
? sanitizeString(normalizedRequestText.value)
|
||||
: undefined,
|
||||
normalizedResponse: normalizedResponseText
|
||||
? sanitizeString(normalizedResponseText.value)
|
||||
: undefined,
|
||||
truncated:
|
||||
requestBodyText.truncated ||
|
||||
responseBodyText.truncated ||
|
||||
(normalizedRequestText?.truncated ?? false) ||
|
||||
(normalizedResponseText?.truncated ?? false),
|
||||
statusCode: data.statusCode,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
// Flush immediately if buffer is full, otherwise schedule a flush
|
||||
if (sessionLogBuffer.length >= BATCH_SIZE) {
|
||||
flushSessionLogs().catch((err) =>
|
||||
logger.error("Error flushing AI session logs:", err)
|
||||
);
|
||||
} else {
|
||||
scheduleFlush();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to log AI session", { error });
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -191,7 +191,7 @@ registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/alert-rule",
|
||||
description: "Create an alert rule for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
tags: [OpenAPITags.AlertRule],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
|
||||
@@ -34,7 +34,7 @@ registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/org/{orgId}/alert-rule/{alertRuleId}",
|
||||
description: "Delete an alert rule for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
tags: [OpenAPITags.AlertRule],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
|
||||
@@ -48,7 +48,7 @@ registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/alert-rule/{alertRuleId}",
|
||||
description: "Get a specific alert rule for an organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
tags: [OpenAPITags.AlertRule],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
|
||||
@@ -15,4 +15,5 @@ export * from "./createAlertRule";
|
||||
export * from "./updateAlertRule";
|
||||
export * from "./deleteAlertRule";
|
||||
export * from "./listAlertRules";
|
||||
export * from "./getAlertRule";
|
||||
export * from "./getAlertRule";
|
||||
export * from "./testAlertRule";
|
||||
|
||||
@@ -90,7 +90,7 @@ registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/alert-rules",
|
||||
description: "List all alert rules for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
tags: [OpenAPITags.AlertRule],
|
||||
request: {
|
||||
query: querySchema,
|
||||
params: paramsSchema
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { getRandomItemInArray } from "@app/lib/getRandomItemInArray";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts";
|
||||
import { type AlertAction } from "@server/routers/alertRule/types";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import type { TriggerSiteAlertResponse } from "../alertEvents";
|
||||
import {
|
||||
HC_EVENT_TYPES,
|
||||
SITE_EVENT_TYPES,
|
||||
RESOURCE_EVENT_TYPES
|
||||
} from "./createAlertRule";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const webhookActionSchema = z.strictObject({
|
||||
webhookUrl: z.url(),
|
||||
config: z.string().optional(),
|
||||
enabled: z.boolean().optional().default(true)
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
eventType: z.enum([
|
||||
...HC_EVENT_TYPES,
|
||||
...SITE_EVENT_TYPES,
|
||||
...RESOURCE_EVENT_TYPES
|
||||
]),
|
||||
// Email recipients (flat)
|
||||
userIds: z.array(z.string().nonempty()).optional().default([]),
|
||||
roleIds: z.array(z.number()).optional().default([]),
|
||||
emails: z.array(z.email()).optional().default([]),
|
||||
// Webhook actions
|
||||
webhookActions: z.array(webhookActionSchema).optional().default([])
|
||||
});
|
||||
|
||||
export async function testAlertRule(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
const collectedActions: AlertAction[] = [];
|
||||
if (
|
||||
body.emails.length > 0 ||
|
||||
body.roleIds.length > 0 ||
|
||||
body.userIds.length > 0
|
||||
) {
|
||||
collectedActions.push({
|
||||
type: "email",
|
||||
emails: body.emails,
|
||||
roleIds: body.roleIds,
|
||||
userIds: body.userIds
|
||||
});
|
||||
}
|
||||
|
||||
for (const action of body.webhookActions) {
|
||||
collectedActions.push({
|
||||
type: "webhook",
|
||||
...action
|
||||
});
|
||||
}
|
||||
|
||||
let data: Record<string, any> = {};
|
||||
switch (body.eventType) {
|
||||
case "site_toggle":
|
||||
data = {
|
||||
status: getRandomItemInArray(["online", "offline"]),
|
||||
siteName: "Test Site Alert"
|
||||
};
|
||||
break;
|
||||
case "site_offline":
|
||||
data = {
|
||||
status: "offline",
|
||||
siteName: "Test Site Alert"
|
||||
};
|
||||
break;
|
||||
case "site_online":
|
||||
data = {
|
||||
status: "online",
|
||||
siteName: "Test Site Alert"
|
||||
};
|
||||
break;
|
||||
case "resource_toggle":
|
||||
data = {
|
||||
status: getRandomItemInArray([
|
||||
"healthy",
|
||||
"unhealthy",
|
||||
"degraded"
|
||||
]),
|
||||
siteName: "Test Resource Alert"
|
||||
};
|
||||
break;
|
||||
case "resource_healthy":
|
||||
data = {
|
||||
status: "healthy",
|
||||
siteName: "Test Resource Alert"
|
||||
};
|
||||
break;
|
||||
case "resource_unhealthy":
|
||||
data = {
|
||||
status: "unhealthy",
|
||||
siteName: "Test Resource Alert"
|
||||
};
|
||||
break;
|
||||
case "resource_degraded":
|
||||
data = {
|
||||
status: "degraded",
|
||||
siteName: "Test Resource Alert"
|
||||
};
|
||||
break;
|
||||
case "health_check_toggle":
|
||||
data = {
|
||||
status: getRandomItemInArray(["healthy", "unhealthy"]),
|
||||
healthCheckName: "Test Health Check Alert"
|
||||
};
|
||||
break;
|
||||
case "health_check_healthy":
|
||||
data = {
|
||||
status: "healthy",
|
||||
healthCheckName: "Test Health Check Alert"
|
||||
};
|
||||
break;
|
||||
case "health_check_unhealthy":
|
||||
data = {
|
||||
status: "unhealthy",
|
||||
healthCheckName: "Test Health Check Alert"
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// TODO: process alert rule
|
||||
await processTestAlerts({
|
||||
eventType: body.eventType,
|
||||
orgId,
|
||||
actions: collectedActions,
|
||||
data
|
||||
});
|
||||
|
||||
return response<TriggerSiteAlertResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert triggered successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/alert-rule/{alertRuleId}",
|
||||
description: "Update an alert rule for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
tags: [OpenAPITags.AlertRule],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
|
||||
@@ -134,6 +134,17 @@ async function capRetentionDays(
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
org.settingsLogRetentionDaysAISessions !== null &&
|
||||
org.settingsLogRetentionDaysAISessions > maxRetentionDays
|
||||
) {
|
||||
updates.settingsLogRetentionDaysAISessions = maxRetentionDays;
|
||||
needsUpdate = true;
|
||||
logger.info(
|
||||
`Capping AI session log retention from ${org.settingsLogRetentionDaysAISessions} to ${maxRetentionDays} days for org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Apply updates if needed
|
||||
if (needsUpdate) {
|
||||
await db.update(orgs).set(updates).where(eq(orgs.orgId, orgId));
|
||||
@@ -280,12 +291,16 @@ async function disableFeature(
|
||||
await disableConnectionLogs(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.AISessionLogs:
|
||||
await disableAISessionLogs(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.RotateCredentials:
|
||||
await disableRotateCredentials(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.MaintencePage:
|
||||
await disableMaintencePage(orgId);
|
||||
case TierFeature.MaintenancePage:
|
||||
await disablemaintenancePage(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.DevicePosture:
|
||||
@@ -308,10 +323,6 @@ async function disableFeature(
|
||||
await disableAutoProvisioning(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.AdvancedPrivateResources:
|
||||
await disableAdvancedPrivateResources(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.FullRbac:
|
||||
await disableFullRbac(orgId);
|
||||
break;
|
||||
@@ -357,13 +368,6 @@ async function disableDeviceApprovals(orgId: string): Promise<void> {
|
||||
logger.info(`Disabled device approvals on all roles for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableAdvancedPrivateResources(orgId: string): Promise<void> {
|
||||
// TODO: implement logic to disable advanced private resourcs like ssh and ssh pam
|
||||
// logger.info(
|
||||
// `Disabled advanced private resources on all roles and site resources for org ${orgId}`
|
||||
// );
|
||||
}
|
||||
|
||||
async function disableFullRbac(orgId: string): Promise<void> {
|
||||
logger.info(`Disabled full RBAC for org ${orgId}`);
|
||||
}
|
||||
@@ -493,9 +497,18 @@ async function disableConnectionLogs(orgId: string): Promise<void> {
|
||||
logger.info(`Disabled connection logs for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableAISessionLogs(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({ settingsLogRetentionDaysAISessions: 0 })
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled AI session logs for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableRotateCredentials(orgId: string): Promise<void> {}
|
||||
|
||||
async function disableMaintencePage(orgId: string): Promise<void> {
|
||||
async function disablemaintenancePage(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(resources)
|
||||
.set({
|
||||
|
||||
@@ -53,6 +53,15 @@ export async function handleSubscriptionDeleted(
|
||||
return;
|
||||
}
|
||||
|
||||
// If the subscription has been manually overridden, we lock it down
|
||||
// so Stripe can no longer change (or delete) its status locally.
|
||||
if (existingSubscription.override === true) {
|
||||
logger.info(
|
||||
`Subscription ${subscription.id} is locked (override=true). Ignoring deletion event from Stripe.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(subscriptions)
|
||||
.where(eq(subscriptions.subscriptionId, subscription.id));
|
||||
|
||||
@@ -68,13 +68,27 @@ export async function handleSubscriptionUpdated(
|
||||
const type = getSubType(fullSubscription);
|
||||
const previousType = existingSubscription.type as SubscriptionType | null;
|
||||
|
||||
// If the subscription has been manually overridden, we lock the
|
||||
// status down so Stripe webhooks can no longer change it.
|
||||
const isLocked = existingSubscription.override === true;
|
||||
if (isLocked) {
|
||||
logger.info(
|
||||
`Subscription ${subscription.id} is locked (override=true). Ignoring status change from Stripe (would have been ${subscription.status}).`
|
||||
);
|
||||
}
|
||||
const effectiveStatus = isLocked
|
||||
? existingSubscription.status
|
||||
: subscription.status;
|
||||
|
||||
await db
|
||||
.update(subscriptions)
|
||||
.set({
|
||||
status: subscription.status,
|
||||
canceledAt: subscription.canceled_at
|
||||
? subscription.canceled_at
|
||||
: null,
|
||||
status: effectiveStatus,
|
||||
canceledAt: isLocked
|
||||
? existingSubscription.canceledAt
|
||||
: subscription.canceled_at
|
||||
? subscription.canceled_at
|
||||
: null,
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
billingCycleAnchor: subscription.billing_cycle_anchor,
|
||||
type: type
|
||||
@@ -275,23 +289,23 @@ export async function handleSubscriptionUpdated(
|
||||
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
|
||||
await handleSubscriptionLifesycle(
|
||||
customer.orgId,
|
||||
subscription.status,
|
||||
effectiveStatus,
|
||||
type
|
||||
);
|
||||
|
||||
// Handle feature lifecycle when subscription is canceled or becomes unpaid
|
||||
if (
|
||||
subscription.status === "canceled" ||
|
||||
subscription.status === "unpaid" ||
|
||||
subscription.status === "incomplete_expired"
|
||||
effectiveStatus === "canceled" ||
|
||||
effectiveStatus === "unpaid" ||
|
||||
effectiveStatus === "incomplete_expired"
|
||||
) {
|
||||
logger.info(
|
||||
`Subscription ${subscription.id} for org ${customer.orgId} is ${subscription.status}, disabling paid features`
|
||||
`Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features`
|
||||
);
|
||||
await handleTierChange(customer.orgId, null, previousType ?? undefined);
|
||||
}
|
||||
} else if (type === "license") {
|
||||
if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") {
|
||||
if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") {
|
||||
try {
|
||||
// WARNING:
|
||||
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resources, targets } from "@server/db";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
||||
|
||||
const getBrowserTargetSchema = z
|
||||
.object({
|
||||
fullDomain: z.string().min(1, "fullDomain is required")
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function getBrowserTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsed = getBrowserTargetSchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsed.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { fullDomain } = parsed.data;
|
||||
|
||||
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
ip: targets.ip,
|
||||
port: targets.port,
|
||||
authToken: targets.authToken,
|
||||
resourceId: resources.resourceId,
|
||||
niceId: resources.niceId,
|
||||
name: resources.name,
|
||||
orgId: resources.orgId,
|
||||
pamMode: resources.pamMode,
|
||||
authDaemonMode: resources.authDaemonMode
|
||||
})
|
||||
.from(targets)
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.where(
|
||||
and(
|
||||
eq(resources.fullDomain, fullDomain),
|
||||
eq(targets.enabled, true),
|
||||
inArray(targets.mode, ["ssh", "rdp", "vnc"])
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"No resource found for this domain"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const decryptedAuthToken = row.authToken
|
||||
? decrypt(row.authToken, config.getRawConfig().server.secret!)
|
||||
: "";
|
||||
|
||||
return response<GetBrowserTargetResponse>(res, {
|
||||
data: {
|
||||
ip: row.ip,
|
||||
port: row.port,
|
||||
authToken: decryptedAuthToken,
|
||||
pamMode: row.pamMode,
|
||||
authDaemonMode: row.authDaemonMode,
|
||||
orgId: row.orgId,
|
||||
resourceId: row.resourceId,
|
||||
niceId: row.niceId,
|
||||
name: row.name ?? ""
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser target retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred while retrieving the browser target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./getBrowserTarget";
|
||||
@@ -1,115 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Certificate, certificates, db, domains } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { Transaction } from "@server/db";
|
||||
import { eq, or, and, like } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Checks if a certificate exists for the given domain.
|
||||
* If not, creates a new certificate in 'pending' state.
|
||||
* Wildcard certs cover subdomains.
|
||||
*/
|
||||
export async function createCertificate(
|
||||
domainId: string,
|
||||
domain: string,
|
||||
trx: Transaction | typeof db
|
||||
) {
|
||||
const [domainRecord] = await trx
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (!domainRecord) {
|
||||
throw new Error(`Domain with ID ${domainId} not found`);
|
||||
}
|
||||
|
||||
let existing: Certificate[] = [];
|
||||
if (domainRecord.type == "ns" || domainRecord.type == "wildcard") {
|
||||
const domainLevelDown = domain.split(".").slice(1).join(".");
|
||||
const wildcardPrefixed = `*.${domainLevelDown}`;
|
||||
|
||||
existing = await trx
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.domainId, domainId),
|
||||
or(
|
||||
eq(certificates.domain, domain),
|
||||
and(
|
||||
eq(certificates.wildcard, true),
|
||||
or(
|
||||
eq(certificates.domain, domainLevelDown),
|
||||
eq(certificates.domain, wildcardPrefixed)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// For non-NS domains, we only match exact domain names
|
||||
existing = await trx
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.domainId, domainId),
|
||||
eq(certificates.domain, domain) // exact match for non-NS domains
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
logger.info(`Certificate already exists for domain ${domain}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let domainToWrite = domain;
|
||||
if (
|
||||
domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD
|
||||
domainRecord.preferWildcardCert &&
|
||||
!domain.startsWith("*.")
|
||||
) {
|
||||
// in this case traefik is going to generate a domain one level down so we need to store it that way
|
||||
const parts = domain.split(".");
|
||||
if (parts.length > 2) {
|
||||
domainToWrite = parts.slice(1).join(".");
|
||||
domainToWrite = `*.${domainToWrite}`;
|
||||
}
|
||||
} else if (domainRecord.type == "ns") {
|
||||
if (domain == domainRecord.baseDomain) {
|
||||
domainToWrite = domainRecord.baseDomain;
|
||||
} else {
|
||||
const parts = domain.split(".");
|
||||
if (parts.length > 2) {
|
||||
domainToWrite = parts.slice(1).join(".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No cert found, create a new one in pending state
|
||||
await trx.insert(certificates).values({
|
||||
domain: domainToWrite,
|
||||
domainId,
|
||||
wildcard:
|
||||
domainRecord.type == "ns" ||
|
||||
(domainRecord.type == "wildcard" &&
|
||||
domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains
|
||||
status: "pending",
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
createdAt: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { certificates, db, domains } from "@server/db";
|
||||
import { eq, and, or, like } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { registry } from "@server/openApi";
|
||||
import { GetCertificateResponse } from "@server/routers/certificates/types";
|
||||
|
||||
const getCertificateSchema = z.strictObject({
|
||||
domainId: z.string(),
|
||||
domain: z.string().min(1).max(255),
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
async function query(domainId: string, domain: string) {
|
||||
const [domainRecord] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (!domainRecord) {
|
||||
throw new Error(`Domain with ID ${domainId} not found`);
|
||||
}
|
||||
|
||||
const domainType = domainRecord.type;
|
||||
|
||||
let existing: any[] = [];
|
||||
if (domainRecord.type == "ns" || domainRecord.type == "wildcard") {
|
||||
const domainLevelDown = domain.split(".").slice(1).join(".");
|
||||
const wildcardPrefixed = `*.${domainLevelDown}`;
|
||||
|
||||
existing = await db
|
||||
.select({
|
||||
certId: certificates.certId,
|
||||
domain: certificates.domain,
|
||||
wildcard: certificates.wildcard,
|
||||
status: certificates.status,
|
||||
expiresAt: certificates.expiresAt,
|
||||
lastRenewalAttempt: certificates.lastRenewalAttempt,
|
||||
createdAt: certificates.createdAt,
|
||||
updatedAt: certificates.updatedAt,
|
||||
errorMessage: certificates.errorMessage,
|
||||
renewalCount: certificates.renewalCount
|
||||
})
|
||||
.from(certificates)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.domainId, domainId),
|
||||
or(
|
||||
eq(certificates.domain, domain),
|
||||
and(
|
||||
eq(certificates.wildcard, true),
|
||||
or(
|
||||
eq(certificates.domain, domainLevelDown),
|
||||
eq(certificates.domain, wildcardPrefixed)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// For non-NS domains, we only match exact domain names
|
||||
existing = await db
|
||||
.select({
|
||||
certId: certificates.certId,
|
||||
domain: certificates.domain,
|
||||
wildcard: certificates.wildcard,
|
||||
status: certificates.status,
|
||||
expiresAt: certificates.expiresAt,
|
||||
lastRenewalAttempt: certificates.lastRenewalAttempt,
|
||||
createdAt: certificates.createdAt,
|
||||
updatedAt: certificates.updatedAt,
|
||||
errorMessage: certificates.errorMessage,
|
||||
renewalCount: certificates.renewalCount
|
||||
})
|
||||
.from(certificates)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.domainId, domainId),
|
||||
eq(certificates.domain, domain) // exact match for non-NS domains
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return existing.length > 0 ? { ...existing[0], domainType } : null;
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/certificate/{domainId}/{domain}",
|
||||
description: "Get a certificate by domain.",
|
||||
tags: ["Certificate"],
|
||||
request: {
|
||||
params: z.object({
|
||||
domainId: z.string(),
|
||||
domain: z.string().min(1).max(255),
|
||||
orgId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function getCertificate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = getCertificateSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { domainId, domain } = parsedParams.data;
|
||||
|
||||
const cert = await query(domainId, domain);
|
||||
|
||||
if (!cert) {
|
||||
logger.warn(`Certificate not found for domain: ${domainId}`);
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Certificate not found")
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetCertificateResponse>(res, {
|
||||
data: cert,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Certificate retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./getCertificate";
|
||||
export * from "./restartCertificate";
|
||||
export * from "./syncCertToNewts";
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { certificates, db } from "@server/db";
|
||||
import { sites } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const restartCertificateParamsSchema = z.strictObject({
|
||||
certId: z.coerce.number().int().positive(),
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/certificate/{certId}",
|
||||
description: "Restart a certificate by ID.",
|
||||
tags: ["Certificate"],
|
||||
request: {
|
||||
params: z.object({
|
||||
certId: z.coerce.number().int().positive(),
|
||||
orgId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function restartCertificate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = restartCertificateParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { certId } = parsedParams.data;
|
||||
|
||||
// get the certificate by ID
|
||||
const [cert] = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.certId, certId))
|
||||
.limit(1);
|
||||
|
||||
if (!cert) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Certificate not found")
|
||||
);
|
||||
}
|
||||
|
||||
if (cert.status != "failed" && cert.status != "expired") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Certificate is already valid, no need to restart"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// update the certificate status to 'pending'
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
status: "pending",
|
||||
errorMessage: null,
|
||||
lastRenewalAttempt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.where(eq(certificates.certId, certId));
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Certificate restarted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { pushCertUpdateToAffectedNewts } from "#private/lib/acmeCertSync";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const bodySchema = z.object({
|
||||
domain: z.string().min(1),
|
||||
domainId: z.string().nullable().optional().default(null)
|
||||
});
|
||||
|
||||
export async function syncCertToNewts(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
const parsed = bodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsed.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { domain, domainId } = parsed.data;
|
||||
|
||||
logger.debug(
|
||||
`syncCertToNewts: received request to push cert update for domain "${domain}" (domainId: ${domainId ?? "none"})`
|
||||
);
|
||||
|
||||
try {
|
||||
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||
|
||||
res.status(HttpCode.OK).json({
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: `Certificate update pushed to affected newts for domain "${domain}"`
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`syncCertToNewts: error pushing cert update for domain "${domain}": ${err}`
|
||||
);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to push certificate update to affected newts"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/event-streaming-destination",
|
||||
description: "Create an event streaming destination for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
tags: [OpenAPITags.EventStreamingDestination],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
|
||||
@@ -35,7 +35,7 @@ registry.registerPath({
|
||||
path: "/org/{orgId}/event-streaming-destination/{destinationId}",
|
||||
description:
|
||||
"Delete an event streaming destination for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
tags: [OpenAPITags.EventStreamingDestination],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
|
||||
@@ -109,7 +109,7 @@ registry.registerPath({
|
||||
path: "/org/{orgId}/event-streaming-destination",
|
||||
description:
|
||||
"List all event streaming destinations for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
tags: [OpenAPITags.EventStreamingDestination],
|
||||
request: {
|
||||
query: querySchema,
|
||||
params: paramsSchema
|
||||
|
||||
@@ -55,7 +55,7 @@ registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/event-streaming-destination/{destinationId}",
|
||||
description: "Update an event streaming destination for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
tags: [OpenAPITags.EventStreamingDestination],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import * as certificates from "#private/routers/certificates";
|
||||
import { createStore } from "#private/lib/rateLimitStore";
|
||||
import * as billing from "#private/routers/billing";
|
||||
import * as remoteExitNode from "#private/routers/remoteExitNode";
|
||||
@@ -20,19 +19,20 @@ import * as orgIdp from "#private/routers/orgIdp";
|
||||
import * as domain from "#private/routers/domain";
|
||||
import * as auth from "#private/routers/auth";
|
||||
import * as license from "#private/routers/license";
|
||||
import * as generateLicense from "./generatedLicense";
|
||||
import * as generateLicense from "#private/routers/generatedLicense";
|
||||
import * as logs from "#private/routers/auditLogs";
|
||||
import {
|
||||
queryAiSessionLogs,
|
||||
exportAiSessionLogs
|
||||
} from "@server/routers/auditLogs";
|
||||
import * as misc from "#private/routers/misc";
|
||||
import * as reKey from "#private/routers/re-key";
|
||||
import * as approval from "#private/routers/approvals";
|
||||
import * as ssh from "#private/routers/ssh";
|
||||
import * as user from "#private/routers/user";
|
||||
import * as siteProvisioning from "#private/routers/siteProvisioning";
|
||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||
import * as alertRule from "#private/routers/alertRule";
|
||||
import * as healthChecks from "#private/routers/healthChecks";
|
||||
import * as client from "@server/routers/client";
|
||||
import * as resource from "#private/routers/resource";
|
||||
import * as policy from "#private/routers/policy";
|
||||
|
||||
import {
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import {
|
||||
logActionAudit,
|
||||
verifyCertificateAccess,
|
||||
verifyIdpAccess,
|
||||
verifyLoginPageAccess,
|
||||
verifyRemoteExitNodeAccess,
|
||||
@@ -167,25 +166,6 @@ authenticated.get(
|
||||
orgIdp.listUserAdminOrgIdps
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/certificate/:domainId/:domain",
|
||||
verifyOrgAccess,
|
||||
verifyCertificateAccess,
|
||||
verifyUserHasAction(ActionsEnum.getCertificate),
|
||||
certificates.getCertificate
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/certificate/:certId/restart",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyCertificateAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.restartCertificate),
|
||||
logActionAudit(ActionsEnum.restartCertificate),
|
||||
certificates.restartCertificate
|
||||
);
|
||||
|
||||
if (build === "saas") {
|
||||
authenticated.post(
|
||||
"/org/:orgId/billing/create-checkout-session",
|
||||
@@ -615,6 +595,25 @@ authenticated.get(
|
||||
logs.exportConnectionAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.aiSessionLogs),
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.viewLogs),
|
||||
queryAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/export",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.aiSessionLogs),
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
exportAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/re-key/:clientId/regenerate-client-secret",
|
||||
verifyClientAccess, // this is first to set the org id
|
||||
@@ -645,17 +644,6 @@ authenticated.put(
|
||||
reKey.reGenerateExitNodeSecret
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/ssh/sign-key",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.advancedPrivateResources),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
// verifyUserHasAction(ActionsEnum.signSshKey), // this check happens inside of the function now
|
||||
// logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata
|
||||
ssh.signSshKey
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/user/:userId/add-role/:roleId",
|
||||
verifyRoleAccess,
|
||||
@@ -801,6 +789,14 @@ authenticated.get(
|
||||
alertRule.listAlertRules
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/test-alert-rule",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.testAlertRule),
|
||||
alertRule.testAlertRule
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/alert-rule/:alertRuleId",
|
||||
verifyValidLicense,
|
||||
@@ -854,15 +850,11 @@ authenticated.get(
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/client/:clientId/verify-associations-cache",
|
||||
verifyClientAccess,
|
||||
client.verifyClientAssociationsCache
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/client/:clientId/rebuild-associations-cache",
|
||||
verifyClientAccess,
|
||||
client.rebuildClientAssociationsCacheRoute
|
||||
"/org/:orgId/health-check-status-histories",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getTarget),
|
||||
healthChecks.getBatchedHealthCheckStatusHistory
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
|
||||
@@ -34,10 +34,6 @@ export async function createExitNode(
|
||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||
// const listenPort = await getNextAvailablePort();
|
||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||
let subEndpoint = "";
|
||||
if (config.getRawConfig().gerbil.use_subdomain) {
|
||||
subEndpoint = await getUniqueExitNodeEndpointName();
|
||||
}
|
||||
|
||||
const exitNodeName =
|
||||
config.getRawConfig().gerbil.exit_node_name ||
|
||||
@@ -48,7 +44,7 @@ export async function createExitNode(
|
||||
.insert(exitNodes)
|
||||
.values({
|
||||
publicKey,
|
||||
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
|
||||
endpoint: config.getRawConfig().gerbil.base_endpoint,
|
||||
address,
|
||||
listenPort,
|
||||
online: true,
|
||||
|
||||
@@ -75,7 +75,7 @@ registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/health-check",
|
||||
description: "Create a health check for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
tags: [OpenAPITags.HealthCheck],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
|
||||
@@ -37,7 +37,7 @@ registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/org/{orgId}/health-check/{healthCheckId}",
|
||||
description: "Delete a health check for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
tags: [OpenAPITags.HealthCheck],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import response from "@server/lib/response";
|
||||
import {
|
||||
getBatchedStatusHistory,
|
||||
type BatchedStatusHistoryResponse
|
||||
} from "@server/lib/statusHistory";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const healthCheckIdParamsSchema = z.object({
|
||||
days: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v ? parseInt(v, 10) : 90)),
|
||||
// Minutes to add to UTC to get the requesting client's local time
|
||||
// (e.g. Australia/Sydney standard time is 600). Optional and
|
||||
// defaults to 0 (UTC) so older clients keep the prior behavior.
|
||||
tzOffsetMinutes: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v ? parseInt(v, 10) : 0)),
|
||||
healthCheckIds: z
|
||||
.preprocess((val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return undefined;
|
||||
}
|
||||
const raw = Array.isArray(val) ? val : [val];
|
||||
const nums = raw
|
||||
.map((v) =>
|
||||
typeof v === "string" ? parseInt(v, 10) : Number(v)
|
||||
)
|
||||
.filter((n) => Number.isInteger(n) && n > 0);
|
||||
const unique = [...new Set(nums)];
|
||||
return unique.length ? unique : undefined;
|
||||
}, z.array(z.number().int().positive()))
|
||||
.openapi({
|
||||
description: "Filter by healthCheckIds (repeat query param)"
|
||||
})
|
||||
});
|
||||
|
||||
export async function getBatchedHealthCheckStatusHistory(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = healthCheckIdParamsSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const entityType = "health_check";
|
||||
const { days, healthCheckIds, tzOffsetMinutes } = parsedQuery.data;
|
||||
|
||||
const data = await getBatchedStatusHistory(
|
||||
entityType,
|
||||
healthCheckIds,
|
||||
days,
|
||||
tzOffsetMinutes
|
||||
);
|
||||
|
||||
return response<BatchedStatusHistoryResponse>(res, {
|
||||
data,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Status history retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,9 +55,14 @@ export async function getHealthCheckStatusHistory(
|
||||
|
||||
const entityType = "health_check";
|
||||
const entityId = parsedParams.data.healthCheckId;
|
||||
const { days } = parsedQuery.data;
|
||||
const { days, tzOffsetMinutes } = parsedQuery.data;
|
||||
|
||||
const data = await getCachedStatusHistory(entityType, entityId, days);
|
||||
const data = await getCachedStatusHistory(
|
||||
entityType,
|
||||
entityId,
|
||||
days,
|
||||
tzOffsetMinutes
|
||||
);
|
||||
|
||||
return response<StatusHistoryResponse>(res, {
|
||||
data,
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from "./createHealthCheck";
|
||||
export * from "./updateHealthCheck";
|
||||
export * from "./deleteHealthCheck";
|
||||
export * from "./getStatusHistory";
|
||||
export * from "./getBatchedStatusHistory";
|
||||
|
||||
@@ -63,7 +63,7 @@ registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/health-checks",
|
||||
description: "List health checks for an organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
tags: [OpenAPITags.HealthCheck],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
query: querySchema
|
||||
|
||||
@@ -109,7 +109,7 @@ registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/health-check/{healthCheckId}",
|
||||
description: "Update a health check for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
tags: [OpenAPITags.HealthCheck],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
|
||||
@@ -58,7 +58,8 @@ import {
|
||||
resourceRules,
|
||||
resourcePolicyRules,
|
||||
userOrgRoles,
|
||||
roles
|
||||
roles,
|
||||
resourceAccessToken
|
||||
} from "@server/db";
|
||||
import { eq, and, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import { alias } from "@server/db";
|
||||
@@ -81,11 +82,16 @@ import config from "@server/lib/config";
|
||||
import { exchangeSession } from "@server/routers/badger";
|
||||
import {
|
||||
ResourceSessionValidationResult,
|
||||
createResourceSession,
|
||||
serializeResourceSessionCookie,
|
||||
validateResourceSessionToken
|
||||
} from "@server/auth/sessions/resource";
|
||||
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
|
||||
import { maxmindLookup } from "@server/db/maxmind";
|
||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
|
||||
import semver from "semver";
|
||||
import { maxmindAsnLookup } from "@server/db/maxmindAsn";
|
||||
import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy";
|
||||
@@ -178,6 +184,73 @@ const validateResourceAccessTokenBodySchema = z.strictObject({
|
||||
accessToken: z.string()
|
||||
});
|
||||
|
||||
const createAccessTokenSessionParamsSchema = z.strictObject({
|
||||
resourceId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const createAccessTokenSessionBodySchema = z.strictObject({
|
||||
accessTokenId: z.string().min(1)
|
||||
});
|
||||
|
||||
const getAccessTokenParamsSchema = z.strictObject({
|
||||
accessTokenId: z.string().min(1)
|
||||
});
|
||||
|
||||
const logAccessAuditBodySchema = z.strictObject({
|
||||
action: z.boolean(),
|
||||
type: z.string(),
|
||||
orgId: z.string(),
|
||||
resourceId: z.number().optional(),
|
||||
siteResourceId: z.number().optional(),
|
||||
user: z
|
||||
.object({
|
||||
username: z.string(),
|
||||
userId: z.string()
|
||||
})
|
||||
.optional(),
|
||||
apiKey: z
|
||||
.object({
|
||||
name: z.string().nullable(),
|
||||
apiKeyId: z.string()
|
||||
})
|
||||
.optional(),
|
||||
metadata: z.any().optional(),
|
||||
userAgent: z.string().optional(),
|
||||
requestIp: z.string().optional()
|
||||
});
|
||||
|
||||
type AccessTokenUserData = {
|
||||
userId: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
role: string | null;
|
||||
};
|
||||
|
||||
async function resolveAccessTokenUserData(
|
||||
userId: string,
|
||||
orgId: string
|
||||
): Promise<AccessTokenUserData | undefined> {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
|
||||
return {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
|
||||
};
|
||||
}
|
||||
|
||||
// Certificates by domains query validation
|
||||
const getCertificatesByDomainsQuerySchema = z.strictObject({
|
||||
// Accept domains as string or array (domains or domains[])
|
||||
@@ -278,6 +351,7 @@ hybridRouter.get(
|
||||
}
|
||||
|
||||
const pangolinUIUrl = config.getRawConfig().app.dashboard_url; // points to the dashboard to serve from there
|
||||
const aiGatewayUrl = `${config.getRawConfig().app.dashboard_url}/api/v1/ai-gateway`;
|
||||
|
||||
try {
|
||||
const traefikConfig = await getTraefikConfig(
|
||||
@@ -286,8 +360,9 @@ hybridRouter.get(
|
||||
true, // But don't allow domain namespace resources
|
||||
false, // Dont include login pages,
|
||||
true, // allow raw resources
|
||||
pangolinUIUrl, // dont generate maintenance page
|
||||
pangolinUIUrl // generate browser gateway targets
|
||||
pangolinUIUrl, // generate maintenance page
|
||||
pangolinUIUrl, // generate browser gateway targets
|
||||
null // dont generate ai gateway resources
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
@@ -1829,8 +1904,23 @@ hybridRouter.post(
|
||||
resourceId
|
||||
});
|
||||
|
||||
let userData: AccessTokenUserData | undefined;
|
||||
if (
|
||||
result.valid &&
|
||||
result.tokenItem?.userId &&
|
||||
result.tokenItem.orgId
|
||||
) {
|
||||
userData = await resolveAccessTokenUserData(
|
||||
result.tokenItem.userId,
|
||||
result.tokenItem.orgId
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: result,
|
||||
data: {
|
||||
...result,
|
||||
userData
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: result.valid
|
||||
@@ -1850,6 +1940,233 @@ hybridRouter.post(
|
||||
}
|
||||
);
|
||||
|
||||
// Create a resource session from a valid access token (for remote nodes)
|
||||
hybridRouter.post(
|
||||
"/resource/:resourceId/session/create-access-token",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const parsedParams = createAccessTokenSessionParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = createAccessTokenSessionBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
const { accessTokenId } = parsedBody.data;
|
||||
|
||||
const [tokenItem] = await db
|
||||
.select()
|
||||
.from(resourceAccessToken)
|
||||
.where(eq(resourceAccessToken.accessTokenId, accessTokenId))
|
||||
.limit(1);
|
||||
|
||||
if (!tokenItem || tokenItem.resourceId !== resourceId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Access token not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!tokenItem.persistSession) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Access token does not allow session persistence"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource || !resource.fullDomain) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||
);
|
||||
}
|
||||
|
||||
const token = generateSessionToken();
|
||||
const sess = await createResourceSession({
|
||||
resourceId: resource.resourceId,
|
||||
token,
|
||||
accessTokenId: tokenItem.accessTokenId,
|
||||
sessionLength: tokenItem.sessionLength,
|
||||
expiresAt: tokenItem.expiresAt,
|
||||
doNotExtend: tokenItem.expiresAt ? true : false
|
||||
});
|
||||
|
||||
const cookieName = config.getRawConfig().server.session_cookie_name;
|
||||
const cookie = serializeResourceSessionCookie(
|
||||
cookieName,
|
||||
resource.fullDomain,
|
||||
token,
|
||||
!resource.ssl,
|
||||
new Date(sess.expiresAt)
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
data: { cookie },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Access token session created successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create access token session"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Resolve access token metadata for remote nodes (cookie session path)
|
||||
hybridRouter.get(
|
||||
"/resource/access-token/:accessTokenId",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const parsedParams = getAccessTokenParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { accessTokenId } = parsedParams.data;
|
||||
|
||||
const [tokenItem] = await db
|
||||
.select()
|
||||
.from(resourceAccessToken)
|
||||
.where(eq(resourceAccessToken.accessTokenId, accessTokenId))
|
||||
.limit(1);
|
||||
|
||||
if (!tokenItem) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Access token not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let userData: AccessTokenUserData | undefined;
|
||||
if (tokenItem.userId) {
|
||||
userData = await resolveAccessTokenUserData(
|
||||
tokenItem.userId,
|
||||
tokenItem.orgId
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: { tokenItem, userData },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Access token retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to get access token"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Access audit log from remote nodes
|
||||
hybridRouter.post(
|
||||
"/logs/access",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const parsedBody = logAccessAuditBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const remoteExitNode = req.remoteExitNode;
|
||||
if (!remoteExitNode || !remoteExitNode.exitNodeId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Remote exit node not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
await checkExitNodeOrg(
|
||||
remoteExitNode.exitNodeId,
|
||||
parsedBody.data.orgId
|
||||
)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Exit node not allowed for this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await logAccessAudit(parsedBody.data);
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Access audit log saved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to save access audit log"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const geoIpLookupParamsSchema = z.object({
|
||||
ip: z.union([z.ipv4(), z.ipv6()])
|
||||
});
|
||||
|
||||
@@ -15,7 +15,12 @@ import * as orgIdp from "#private/routers/orgIdp";
|
||||
import * as org from "#private/routers/org";
|
||||
import * as logs from "#private/routers/auditLogs";
|
||||
import * as alertEvents from "#private/routers/alertEvents";
|
||||
import * as certificates from "#private/routers/certificates";
|
||||
import * as certificates from "@server/routers/certificates";
|
||||
import * as siteProvisioning from "#private/routers/siteProvisioning";
|
||||
import * as policy from "#private/routers/policy";
|
||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||
import * as alertRule from "#private/routers/alertRule";
|
||||
import * as healthChecks from "#private/routers/healthChecks";
|
||||
|
||||
import {
|
||||
verifyApiKeyHasAction,
|
||||
@@ -24,6 +29,8 @@ import {
|
||||
verifyApiKeyIdpAccess,
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyApiKeyUserAccess,
|
||||
verifyApiKeySiteProvisioningKeyAccess,
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits
|
||||
} from "@server/middlewares";
|
||||
import * as user from "#private/routers/user";
|
||||
@@ -36,6 +43,10 @@ import {
|
||||
unauthenticated as ua,
|
||||
authenticated as a
|
||||
} from "@server/routers/integration";
|
||||
import {
|
||||
queryAiSessionLogs,
|
||||
exportAiSessionLogs
|
||||
} from "@server/routers/auditLogs";
|
||||
import { logActionAudit } from "#private/middlewares";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { build } from "@server/build";
|
||||
@@ -146,6 +157,25 @@ authenticated.get(
|
||||
logs.exportConnectionAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.aiSessionLogs),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.viewLogs),
|
||||
queryAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/export",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.aiSessionLogs),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
exportAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/idp/oidc",
|
||||
verifyValidLicense,
|
||||
@@ -215,3 +245,192 @@ authenticated.delete(
|
||||
logActionAudit(ActionsEnum.removeUserRole),
|
||||
user.removeUserRole
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/site-provisioning-key",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createSiteProvisioningKey),
|
||||
logActionAudit(ActionsEnum.createSiteProvisioningKey),
|
||||
siteProvisioning.createSiteProvisioningKey
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/site-provisioning-keys",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listSiteProvisioningKeys),
|
||||
siteProvisioning.listSiteProvisioningKeys
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/site-provisioning-key/:siteProvisioningKeyId",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeySiteProvisioningKeyAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteSiteProvisioningKey),
|
||||
logActionAudit(ActionsEnum.deleteSiteProvisioningKey),
|
||||
siteProvisioning.deleteSiteProvisioningKey
|
||||
);
|
||||
|
||||
authenticated.patch(
|
||||
"/org/:orgId/site-provisioning-key/:siteProvisioningKeyId",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeySiteProvisioningKeyAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateSiteProvisioningKey),
|
||||
logActionAudit(ActionsEnum.updateSiteProvisioningKey),
|
||||
siteProvisioning.updateSiteProvisioningKey
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
["/org/:orgId/resource-policies", "/org/:orgId/public-resource-policies"],
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.listResourcePolicies),
|
||||
logActionAudit(ActionsEnum.listResourcePolicies),
|
||||
policy.listResourcePolicies
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
["/org/:orgId/resource-policy", "/org/:orgId/public-resource-policy"],
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createResourcePolicy),
|
||||
logActionAudit(ActionsEnum.createResourcePolicy),
|
||||
policy.createResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
[
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
"/public-resource-policy/:resourcePolicyId"
|
||||
],
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteResourcePolicy),
|
||||
logActionAudit(ActionsEnum.deleteResourcePolicy),
|
||||
policy.deleteResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/event-streaming-destination",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createEventStreamingDestination),
|
||||
logActionAudit(ActionsEnum.createEventStreamingDestination),
|
||||
eventStreamingDestination.createEventStreamingDestination
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/event-streaming-destination/:destinationId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateEventStreamingDestination),
|
||||
logActionAudit(ActionsEnum.updateEventStreamingDestination),
|
||||
eventStreamingDestination.updateEventStreamingDestination
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/event-streaming-destination/:destinationId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteEventStreamingDestination),
|
||||
logActionAudit(ActionsEnum.deleteEventStreamingDestination),
|
||||
eventStreamingDestination.deleteEventStreamingDestination
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/event-streaming-destinations",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listEventStreamingDestinations),
|
||||
eventStreamingDestination.listEventStreamingDestinations
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/alert-rule",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createAlertRule),
|
||||
logActionAudit(ActionsEnum.createAlertRule),
|
||||
alertRule.createAlertRule
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/alert-rule/:alertRuleId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateAlertRule),
|
||||
logActionAudit(ActionsEnum.updateAlertRule),
|
||||
alertRule.updateAlertRule
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/alert-rule/:alertRuleId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteAlertRule),
|
||||
logActionAudit(ActionsEnum.deleteAlertRule),
|
||||
alertRule.deleteAlertRule
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/alert-rules",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listAlertRules),
|
||||
alertRule.listAlertRules
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/alert-rule/:alertRuleId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getAlertRule),
|
||||
alertRule.getAlertRule
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/health-checks",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listHealthChecks),
|
||||
healthChecks.listHealthChecks
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/health-check",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createHealthCheck),
|
||||
logActionAudit(ActionsEnum.createHealthCheck),
|
||||
healthChecks.createHealthCheck
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/health-check/:healthCheckId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateHealthCheck),
|
||||
logActionAudit(ActionsEnum.updateHealthCheck),
|
||||
healthChecks.updateHealthCheck
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/health-check/:healthCheckId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteHealthCheck),
|
||||
logActionAudit(ActionsEnum.deleteHealthCheck),
|
||||
healthChecks.deleteHealthCheck
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/health-check/:healthCheckId/status-history",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getTarget),
|
||||
healthChecks.getHealthCheckStatusHistory
|
||||
);
|
||||
|
||||
@@ -17,14 +17,8 @@ import * as orgIdp from "#private/routers/orgIdp";
|
||||
import * as billing from "#private/routers/billing";
|
||||
import * as license from "#private/routers/license";
|
||||
import * as resource from "#private/routers/resource";
|
||||
import * as ssh from "#private/routers/ssh";
|
||||
import * as ws from "@server/routers/ws";
|
||||
import * as browserTarget from "#private/routers/browserGatewayTarget";
|
||||
|
||||
import {
|
||||
verifySessionUserMiddleware,
|
||||
verifyUserFromResourceSessionMiddleware
|
||||
} from "@server/middlewares";
|
||||
import { verifySessionUserMiddleware } from "@server/middlewares";
|
||||
|
||||
import { internalRouter as ir } from "@server/routers/internal";
|
||||
|
||||
@@ -46,17 +40,3 @@ internalRouter.post(
|
||||
internalRouter.get(`/license/status`, license.getLicenseStatus);
|
||||
|
||||
internalRouter.get("/maintenance/info", resource.getMaintenanceInfo);
|
||||
|
||||
internalRouter.post(
|
||||
"/org/:orgId/ssh/sign-key",
|
||||
verifyUserFromResourceSessionMiddleware,
|
||||
ssh.signSshKey
|
||||
);
|
||||
|
||||
internalRouter.get(
|
||||
"/ws/round-trip-message/:messageId",
|
||||
verifyUserFromResourceSessionMiddleware,
|
||||
ws.checkRoundTripMessage
|
||||
);
|
||||
|
||||
internalRouter.get("/resource/browser-target", browserTarget.getBrowserTarget);
|
||||
|
||||
@@ -29,7 +29,7 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
||||
import { createCertificate } from "@server/routers/certificates/createCertificate";
|
||||
|
||||
import { CreateLoginPageResponse } from "@server/routers/loginPage/types";
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { fromError } from "zod-validation-error";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
||||
import { createCertificate } from "@server/routers/certificates/createCertificate";
|
||||
|
||||
import { UpdateLoginPageResponse } from "@server/routers/loginPage/types";
|
||||
|
||||
@@ -85,7 +85,6 @@ export async function updateLoginPage(
|
||||
|
||||
const { loginPageId, orgId } = parsedParams.data;
|
||||
|
||||
|
||||
const [existingLoginPage] = await db
|
||||
.select()
|
||||
.from(loginPage)
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
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;
|
||||
}
|
||||
|
||||
const zlibInflate = promisify(inflate);
|
||||
|
||||
interface HTTPRequestLogData {
|
||||
requestId: string;
|
||||
resourceId: number; // siteResourceId
|
||||
timestamp: string; // ISO 8601
|
||||
method: string;
|
||||
scheme: string; // "http" or "https"
|
||||
host: string;
|
||||
path: string;
|
||||
rawQuery?: string;
|
||||
userAgent?: string;
|
||||
sourceAddr: string; // ip:port
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a base64-encoded zlib-compressed string into parsed JSON.
|
||||
*/
|
||||
async function decompressRequestLog(
|
||||
compressed: string
|
||||
): Promise<HTTPRequestLogData[]> {
|
||||
const compressedBuffer = Buffer.from(compressed, "base64");
|
||||
const decompressed = await zlibInflate(compressedBuffer);
|
||||
const jsonString = decompressed.toString("utf-8");
|
||||
const parsed = JSON.parse(jsonString);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("Decompressed request log data is not an array");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const handleRequestLogMessage: MessageHandler = async (context) => {
|
||||
const { message, client } = context;
|
||||
const newt = client as Newt;
|
||||
|
||||
if (!newt) {
|
||||
logger.warn("Request log received but no newt client in context");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newt.siteId) {
|
||||
logger.warn("Request log received but newt has no siteId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.data?.compressed) {
|
||||
logger.warn("Request log message missing compressed data");
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the org for this site and check retention settings
|
||||
const [site] = await db
|
||||
.select({
|
||||
orgId: sites.orgId,
|
||||
orgSubnet: orgs.subnet,
|
||||
settingsLogRetentionDaysRequest:
|
||||
orgs.settingsLogRetentionDaysRequest
|
||||
})
|
||||
.from(sites)
|
||||
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||
.where(eq(sites.siteId, newt.siteId));
|
||||
|
||||
if (!site) {
|
||||
logger.warn(
|
||||
`Request log received but site ${newt.siteId} not found in database`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const orgId = site.orgId;
|
||||
|
||||
if (site.settingsLogRetentionDaysRequest === 0) {
|
||||
logger.debug(
|
||||
`Request log retention is disabled for org ${orgId}, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: HTTPRequestLogData[];
|
||||
try {
|
||||
entries = await decompressRequestLog(message.data.compressed);
|
||||
} catch (error) {
|
||||
logger.error("Failed to decompress request log data:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 ||
|
||||
!entry.resourceId ||
|
||||
!entry.method ||
|
||||
!entry.scheme ||
|
||||
!entry.host ||
|
||||
!entry.path ||
|
||||
!entry.sourceAddr
|
||||
) {
|
||||
logger.debug(
|
||||
`Skipping request log entry with missing required fields: ${JSON.stringify(entry)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalRequestURL =
|
||||
entry.scheme +
|
||||
"://" +
|
||||
entry.host +
|
||||
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,
|
||||
siteResourceId: entry.resourceId,
|
||||
orgId,
|
||||
location
|
||||
},
|
||||
{
|
||||
path: entry.path,
|
||||
originalRequestURL,
|
||||
scheme: entry.scheme,
|
||||
host: entry.host,
|
||||
method: entry.method,
|
||||
tls: entry.tls,
|
||||
requestIp: entry.sourceAddr
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})`
|
||||
);
|
||||
};
|
||||
@@ -12,4 +12,3 @@
|
||||
*/
|
||||
|
||||
export * from "./handleConnectionLogMessage";
|
||||
export * from "./handleRequestLogMessage";
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
idpOrg,
|
||||
orgs,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
@@ -31,6 +29,7 @@ import {
|
||||
type ResourcePolicy
|
||||
} from "@server/db";
|
||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
|
||||
import response from "@server/lib/response";
|
||||
import {
|
||||
getResourceRuleValueValidationError,
|
||||
@@ -108,7 +107,6 @@ const createResourcePolicyBodySchema = z.strictObject({
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
.optional()
|
||||
.default([]),
|
||||
@@ -121,7 +119,7 @@ registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/resource-policy",
|
||||
description: "Create a resource policy.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
tags: [OpenAPITags.PublicResourcePolicy],
|
||||
request: {
|
||||
params: createResourcePolicyParamsSchema,
|
||||
body: {
|
||||
@@ -204,14 +202,9 @@ export async function createResourcePolicy(
|
||||
|
||||
// Check if Identity provider in `skipToIdpId` exists
|
||||
if (skipToIdpId) {
|
||||
const [provider] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
||||
.where(and(eq(idp.idpId, skipToIdpId), eq(idpOrg.orgId, orgId)))
|
||||
.limit(1);
|
||||
const providerExists = await idpExistsForOrg(skipToIdpId, orgId);
|
||||
|
||||
if (!provider) {
|
||||
if (!providerExists) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -31,7 +31,7 @@ registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/resource-policy/{resourcePolicyId}",
|
||||
description: "Delete a resource policy.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
tags: [OpenAPITags.PublicResourcePolicy],
|
||||
request: {
|
||||
params: deleteResourcePolicySchema
|
||||
},
|
||||
|
||||
@@ -79,7 +79,7 @@ registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/resource-policies",
|
||||
description: "List resource policies for an organization.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
tags: [OpenAPITags.PublicResourcePolicy],
|
||||
request: {
|
||||
params: z.object({
|
||||
orgId: z.string()
|
||||
|
||||
@@ -178,7 +178,7 @@ export async function reGenerateSiteSecret(
|
||||
);
|
||||
}
|
||||
|
||||
if (site.exitNodeId && site.subnet) {
|
||||
if (site.exitNodeId && site.exitNodeSubnet) {
|
||||
await deletePeer(site.exitNodeId, site.pubKey!); // the old pubkey
|
||||
await addPeer(site.exitNodeId, {
|
||||
publicKey: pubKey,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { db, exitNodes, newts, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import redisManager from "#private/lib/redis";
|
||||
import { sendToClient } from "#private/routers/ws";
|
||||
// import { sendToClient } from "#private/routers/ws";
|
||||
|
||||
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
||||
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
||||
@@ -150,47 +150,47 @@ async function processPendingReconnects(): Promise<void> {
|
||||
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
||||
);
|
||||
|
||||
await sendReconnectToNewts(exitNodeId);
|
||||
// await sendReconnectToNewts(exitNodeId);
|
||||
await removePending(exitNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||
try {
|
||||
const connectedNewts = await db
|
||||
.select({ newtId: newts.newtId })
|
||||
.from(newts)
|
||||
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||
.where(eq(sites.exitNodeId, exitNodeId));
|
||||
// async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||
// try {
|
||||
// const connectedNewts = await db
|
||||
// .select({ newtId: newts.newtId })
|
||||
// .from(newts)
|
||||
// .innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||
// .where(eq(sites.exitNodeId, exitNodeId));
|
||||
|
||||
if (connectedNewts.length === 0) {
|
||||
logger.debug(
|
||||
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// if (connectedNewts.length === 0) {
|
||||
// logger.debug(
|
||||
// `No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
logger.info(
|
||||
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||
);
|
||||
// logger.info(
|
||||
// `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||
// );
|
||||
|
||||
const reconnectMessage = {
|
||||
type: "newt/wg/reconnect",
|
||||
data: {}
|
||||
};
|
||||
// const reconnectMessage = {
|
||||
// type: "newt/wg/reconnect",
|
||||
// data: {}
|
||||
// };
|
||||
|
||||
await Promise.allSettled(
|
||||
connectedNewts.map(({ newtId }) =>
|
||||
sendToClient(newtId, reconnectMessage)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||
{ error }
|
||||
);
|
||||
}
|
||||
}
|
||||
// await Promise.allSettled(
|
||||
// connectedNewts.map(({ newtId }) =>
|
||||
// sendToClient(newtId, reconnectMessage)
|
||||
// )
|
||||
// );
|
||||
// } catch (error) {
|
||||
// logger.error(
|
||||
// `Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||
// { error }
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
async function removePending(exitNodeId: number): Promise<void> {
|
||||
pendingReconnects.delete(exitNodeId);
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import logger from "@server/logger";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import type { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types";
|
||||
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
orgId: z.string().nonempty()
|
||||
@@ -34,10 +36,17 @@ const paramsSchema = z.object({
|
||||
const bodySchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(255),
|
||||
maxBatchSize: z.union([
|
||||
z.null(),
|
||||
z.coerce.number().int().positive().max(1_000_000)
|
||||
]),
|
||||
maxBatchSize: z
|
||||
.union([
|
||||
z.null(),
|
||||
z.coerce.number().int().positive().max(1_000_000)
|
||||
])
|
||||
.openapi({
|
||||
type: "number",
|
||||
default: null,
|
||||
description:
|
||||
"Maximum number of sites that can be provisioned in a single batch. If null, there is no limit."
|
||||
}),
|
||||
validUntil: z.string().max(255).optional(),
|
||||
approveNewSites: z.boolean().optional().default(true)
|
||||
})
|
||||
@@ -57,6 +66,49 @@ const bodySchema = z
|
||||
|
||||
export type CreateSiteProvisioningKeyBody = z.infer<typeof bodySchema>;
|
||||
|
||||
const CreateSiteProvisioningKeyResponseDataSchema = z.object({
|
||||
siteProvisioningKeyId: z.string(),
|
||||
orgId: z.string(),
|
||||
name: z.string(),
|
||||
siteProvisioningKey: z.string(),
|
||||
lastChars: z.string(),
|
||||
createdAt: z.string(),
|
||||
lastUsed: z.string().nullable(),
|
||||
maxBatchSize: z.number().nullable(),
|
||||
numUsed: z.number(),
|
||||
validUntil: z.string().nullable(),
|
||||
approveNewSites: z.boolean()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/site-provisioning-key",
|
||||
description: "Create a new site provisioning key for the organization.",
|
||||
tags: [OpenAPITags.SiteProvisioningKey],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createApiResponseSchema(
|
||||
CreateSiteProvisioningKeyResponseDataSchema
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function createSiteProvisioningKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
|
||||
@@ -13,23 +13,46 @@
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
db,
|
||||
siteProvisioningKeyOrg,
|
||||
siteProvisioningKeys
|
||||
} from "@server/db";
|
||||
import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
siteProvisioningKeyId: z.string().nonempty(),
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/org/{orgId}/site-provisioning-key/{siteProvisioningKeyId}",
|
||||
description: "Delete a site provisioning key.",
|
||||
tags: [OpenAPITags.SiteProvisioningKey],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function deleteSiteProvisioningKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
|
||||
@@ -11,11 +11,7 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
siteProvisioningKeyOrg,
|
||||
siteProvisioningKeys
|
||||
} from "@server/db";
|
||||
import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
@@ -25,6 +21,8 @@ import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
|
||||
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
orgId: z.string().nonempty()
|
||||
@@ -45,11 +43,55 @@ const querySchema = z.object({
|
||||
.pipe(z.int().nonnegative())
|
||||
});
|
||||
|
||||
const ListSiteProvisioningKeysResponseDataSchema = z.object({
|
||||
siteProvisioningKeys: z.array(
|
||||
z.object({
|
||||
siteProvisioningKeyId: z.string(),
|
||||
orgId: z.string(),
|
||||
lastChars: z.string(),
|
||||
createdAt: z.string(),
|
||||
name: z.string(),
|
||||
lastUsed: z.string().nullable(),
|
||||
maxBatchSize: z.number().nullable(),
|
||||
numUsed: z.number(),
|
||||
validUntil: z.string().nullable(),
|
||||
approveNewSites: z.boolean()
|
||||
})
|
||||
),
|
||||
pagination: z.object({
|
||||
total: z.number(),
|
||||
limit: z.number(),
|
||||
offset: z.number()
|
||||
})
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/site-provisioning-keys",
|
||||
description: "List all site provisioning keys for an organization.",
|
||||
tags: [OpenAPITags.SiteProvisioningKey],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
query: querySchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createApiResponseSchema(
|
||||
ListSiteProvisioningKeysResponseDataSchema
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function querySiteProvisioningKeys(orgId: string) {
|
||||
return db
|
||||
.select({
|
||||
siteProvisioningKeyId:
|
||||
siteProvisioningKeys.siteProvisioningKeyId,
|
||||
siteProvisioningKeyId: siteProvisioningKeys.siteProvisioningKeyId,
|
||||
orgId: siteProvisioningKeyOrg.orgId,
|
||||
lastChars: siteProvisioningKeys.lastChars,
|
||||
createdAt: siteProvisioningKeys.createdAt,
|
||||
|
||||
@@ -13,11 +13,7 @@
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
db,
|
||||
siteProvisioningKeyOrg,
|
||||
siteProvisioningKeys
|
||||
} from "@server/db";
|
||||
import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -25,6 +21,8 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import type { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types";
|
||||
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
siteProvisioningKeyId: z.string().nonempty(),
|
||||
@@ -38,7 +36,13 @@ const bodySchema = z
|
||||
z.null(),
|
||||
z.coerce.number().int().positive().max(1_000_000)
|
||||
])
|
||||
.optional(),
|
||||
.optional()
|
||||
.openapi({
|
||||
type: "number",
|
||||
default: null,
|
||||
description:
|
||||
"Maximum number of sites that can be provisioned in a single batch. If null, there is no limit."
|
||||
}),
|
||||
validUntil: z.string().max(255).optional(),
|
||||
approveNewSites: z.boolean().optional()
|
||||
})
|
||||
@@ -50,7 +54,8 @@ const bodySchema = z
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Provide maxBatchSize and/or validUntil and/or approveNewSites",
|
||||
message:
|
||||
"Provide maxBatchSize and/or validUntil and/or approveNewSites",
|
||||
path: ["maxBatchSize"]
|
||||
});
|
||||
}
|
||||
@@ -69,6 +74,48 @@ const bodySchema = z
|
||||
|
||||
export type UpdateSiteProvisioningKeyBody = z.infer<typeof bodySchema>;
|
||||
|
||||
const UpdateSiteProvisioningKeyResponseDataSchema = z.object({
|
||||
siteProvisioningKeyId: z.string(),
|
||||
orgId: z.string(),
|
||||
name: z.string(),
|
||||
lastChars: z.string(),
|
||||
createdAt: z.string(),
|
||||
lastUsed: z.string().nullable(),
|
||||
maxBatchSize: z.number().nullable(),
|
||||
numUsed: z.number(),
|
||||
validUntil: z.string().nullable(),
|
||||
approveNewSites: z.boolean()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "patch",
|
||||
path: "/org/{orgId}/site-provisioning-key/{siteProvisioningKeyId}",
|
||||
description: "Update a site provisioning key.",
|
||||
tags: [OpenAPITags.SiteProvisioningKey],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createApiResponseSchema(
|
||||
UpdateSiteProvisioningKeyResponseDataSchema
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function updateSiteProvisioningKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./signSshKey";
|
||||
@@ -1,827 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { randomInt } from "crypto";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
actionAuditLog,
|
||||
db,
|
||||
logsDb,
|
||||
newts,
|
||||
roles,
|
||||
roleActions,
|
||||
rolePolicies,
|
||||
roleResources,
|
||||
roleSiteResources,
|
||||
resources,
|
||||
roundTripMessageTracker,
|
||||
siteResources,
|
||||
siteNetworks,
|
||||
targets,
|
||||
userOrgs,
|
||||
sites,
|
||||
Resource,
|
||||
SiteResource
|
||||
} from "@server/db";
|
||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { and, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
||||
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
||||
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
|
||||
import config from "@server/lib/config";
|
||||
import { sendToClient } from "#private/routers/ws";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const bodySchema = z
|
||||
.strictObject({
|
||||
publicKey: z.string().nonempty(),
|
||||
resourceId: z.number().int().positive().optional(),
|
||||
resource: z.string().nonempty().optional(), // this is either the nice id or the alias
|
||||
username: z.string().nonempty().optional(),
|
||||
type: z.enum(["public", "private"]).default("private")
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const fields = [data.resourceId, data.resource];
|
||||
const definedFields = fields.filter((field) => field !== undefined);
|
||||
return definedFields.length === 1;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Exactly one of resourceId, niceId, or alias must be provided"
|
||||
}
|
||||
);
|
||||
|
||||
export async function signSshKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const {
|
||||
publicKey,
|
||||
resourceId,
|
||||
type,
|
||||
resource: resourceQueryString,
|
||||
username
|
||||
} = parsedBody.data;
|
||||
const userId = req.user?.userId;
|
||||
const roleIds = req.userOrgRoleIds ?? [];
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User has no role in organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [userOrg] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!userOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not belong to the specified organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const roleActionPermission = await db
|
||||
.select({ roleId: roleActions.roleId })
|
||||
.from(roleActions)
|
||||
.where(
|
||||
and(
|
||||
eq(roleActions.actionId, ActionsEnum.signSshKey),
|
||||
inArray(roleActions.roleId, roleIds),
|
||||
eq(roleActions.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (roleActionPermission.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission perform this action"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.advancedPrivateResources
|
||||
);
|
||||
if (!isLicensed) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"SSH key signing requires a paid plan"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get and decrypt the org's CA keys
|
||||
const caKeys = await getOrgCAKeys(
|
||||
orgId,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
if (!caKeys) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"SSH CA not configured for this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let matchingResources: SiteResource[] | Resource[] = [];
|
||||
// Verify the resource exists and belongs to the org.
|
||||
// Build the where clause dynamically based on which field is provided.
|
||||
let whereClause;
|
||||
if (resourceId !== undefined) {
|
||||
whereClause =
|
||||
type === "private"
|
||||
? eq(siteResources.siteResourceId, resourceId)
|
||||
: eq(resources.resourceId, resourceId);
|
||||
} else if (resourceQueryString !== undefined) {
|
||||
whereClause =
|
||||
type === "private"
|
||||
? or(
|
||||
eq(siteResources.niceId, resourceQueryString),
|
||||
eq(siteResources.alias, resourceQueryString)
|
||||
)
|
||||
: eq(resources.niceId, resourceQueryString);
|
||||
} else {
|
||||
// This should never happen due to the schema validation, but TypeScript doesn't know that.
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"One of resourceId, niceId, or alias must be provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "private") {
|
||||
matchingResources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(and(whereClause, eq(siteResources.orgId, orgId)));
|
||||
} else {
|
||||
matchingResources = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(and(whereClause, eq(resources.orgId, orgId)));
|
||||
}
|
||||
|
||||
if (!matchingResources || matchingResources.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Resource not found`)
|
||||
);
|
||||
}
|
||||
|
||||
if (matchingResources.length > 1) {
|
||||
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Multiple resources found matching the criteria`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const resource = matchingResources[0];
|
||||
const normalizedResourceId =
|
||||
type === "private"
|
||||
? (resource as SiteResource).siteResourceId
|
||||
: (resource as Resource).resourceId;
|
||||
|
||||
if (resource.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Resource does not belong to the specified organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (resource.mode == "cidr") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"SSHing is not supported for CIDR resources"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the user has access to the resource
|
||||
const hasAccess =
|
||||
type === "private"
|
||||
? await canUserAccessSiteResource({
|
||||
userId: userId,
|
||||
resourceId: (resource as SiteResource).siteResourceId,
|
||||
roleIds
|
||||
})
|
||||
: await canUserAccessResource({
|
||||
userId: userId,
|
||||
resourceId: (resource as Resource).resourceId,
|
||||
roleIds
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const siteAgentHostMap = new Map<number, string>();
|
||||
let siteIds: number[] = [];
|
||||
|
||||
if (type === "private") {
|
||||
const privateResource = resource as SiteResource;
|
||||
const sitesFromNetworks = await db
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, privateResource.networkId!));
|
||||
|
||||
siteIds = sitesFromNetworks.map((site) => site.siteId);
|
||||
for (const siteId of siteIds) {
|
||||
if (privateResource.destination) {
|
||||
siteAgentHostMap.set(siteId, privateResource.destination);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const publicResource = resource as Resource;
|
||||
const targetRows = await db
|
||||
.select({
|
||||
siteId: targets.siteId,
|
||||
ip: targets.ip
|
||||
})
|
||||
.from(targets)
|
||||
.where(
|
||||
and(
|
||||
eq(targets.resourceId, publicResource.resourceId),
|
||||
eq(targets.enabled, true),
|
||||
eq(targets.mode, "ssh")
|
||||
)
|
||||
);
|
||||
|
||||
if (targetRows.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"No enabled targets found for the resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const targetRow of targetRows) {
|
||||
if (!siteAgentHostMap.has(targetRow.siteId)) {
|
||||
siteAgentHostMap.set(targetRow.siteId, targetRow.ip);
|
||||
}
|
||||
}
|
||||
|
||||
siteIds = Array.from(siteAgentHostMap.keys());
|
||||
}
|
||||
|
||||
let expiresIn: number | undefined;
|
||||
let messageIds: number[] = [];
|
||||
let cert:
|
||||
| {
|
||||
certificate: string;
|
||||
keyId: string;
|
||||
validPrincipals: string[];
|
||||
validAfter: Date;
|
||||
validBefore: Date;
|
||||
}
|
||||
| undefined;
|
||||
// if the pam mode is push then we generate the user's pam username and use that or pull it from the userOrgs table
|
||||
// if the mode is passthrough then just use what was provided because the user will log in themselves
|
||||
let usernameToUse;
|
||||
if (resource.pamMode === "push") {
|
||||
if (!userOrg.pamUsername) {
|
||||
if (req.user?.email) {
|
||||
// Extract username from email (first part before @)
|
||||
usernameToUse = req.user?.email
|
||||
.split("@")[0]
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Unable to extract username from email"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (req.user?.username) {
|
||||
usernameToUse = req.user.username;
|
||||
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
||||
usernameToUse = usernameToUse.replace(
|
||||
/[^a-zA-Z0-9_-]/g,
|
||||
"-"
|
||||
);
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Username is not valid for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User does not have a valid email or username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// prefix with p-
|
||||
usernameToUse = `p-${usernameToUse}`;
|
||||
|
||||
// check if we have a existing user in this org with the same
|
||||
const [existingUserWithSameName] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, usernameToUse)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingUserWithSameName) {
|
||||
let foundUniqueUsername = false;
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const randomNum = randomInt(0, 101); // 0 to 100
|
||||
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, candidateUsername)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
usernameToUse = candidateUsername;
|
||||
foundUniqueUsername = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundUniqueUsername) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Unable to generate a unique username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userOrgs)
|
||||
.set({ pamUsername: usernameToUse })
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.userId, userId)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
usernameToUse = userOrg.pamUsername;
|
||||
}
|
||||
|
||||
type RoleSshMeta = {
|
||||
roleId: number;
|
||||
sshSudoCommands: string | null;
|
||||
sshUnixGroups: string | null;
|
||||
sshCreateHomeDir: boolean | null;
|
||||
sshSudoMode: string | null;
|
||||
};
|
||||
|
||||
let roleRows: RoleSshMeta[] = [];
|
||||
|
||||
if (type === "private") {
|
||||
roleRows = await db
|
||||
.select({
|
||||
roleId: roles.roleId,
|
||||
sshSudoCommands: roles.sshSudoCommands,
|
||||
sshUnixGroups: roles.sshUnixGroups,
|
||||
sshCreateHomeDir: roles.sshCreateHomeDir,
|
||||
sshSudoMode: roles.sshSudoMode
|
||||
})
|
||||
.from(roles)
|
||||
.innerJoin(
|
||||
roleSiteResources,
|
||||
eq(roleSiteResources.roleId, roles.roleId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(roles.roleId, roleIds),
|
||||
eq(
|
||||
roleSiteResources.siteResourceId,
|
||||
(resource as SiteResource).siteResourceId
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const publicResourceId = (resource as Resource).resourceId;
|
||||
const [directRoleRows, policyRoleRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
roleId: roles.roleId,
|
||||
sshSudoCommands: roles.sshSudoCommands,
|
||||
sshUnixGroups: roles.sshUnixGroups,
|
||||
sshCreateHomeDir: roles.sshCreateHomeDir,
|
||||
sshSudoMode: roles.sshSudoMode
|
||||
})
|
||||
.from(roles)
|
||||
.innerJoin(
|
||||
roleResources,
|
||||
eq(roleResources.roleId, roles.roleId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(roles.roleId, roleIds),
|
||||
eq(roleResources.resourceId, publicResourceId)
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({
|
||||
roleId: roles.roleId,
|
||||
sshSudoCommands: roles.sshSudoCommands,
|
||||
sshUnixGroups: roles.sshUnixGroups,
|
||||
sshCreateHomeDir: roles.sshCreateHomeDir,
|
||||
sshSudoMode: roles.sshSudoMode
|
||||
})
|
||||
.from(roles)
|
||||
.innerJoin(
|
||||
rolePolicies,
|
||||
eq(rolePolicies.roleId, roles.roleId)
|
||||
)
|
||||
.innerJoin(
|
||||
resources,
|
||||
or(
|
||||
eq(
|
||||
resources.resourcePolicyId,
|
||||
rolePolicies.resourcePolicyId
|
||||
),
|
||||
and(
|
||||
isNull(resources.resourcePolicyId),
|
||||
eq(
|
||||
resources.defaultResourcePolicyId,
|
||||
rolePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(roles.roleId, roleIds),
|
||||
eq(resources.resourceId, publicResourceId)
|
||||
)
|
||||
)
|
||||
]);
|
||||
|
||||
const uniqueByRoleId = new Map<number, RoleSshMeta>();
|
||||
for (const row of [...directRoleRows, ...policyRoleRows]) {
|
||||
if (!uniqueByRoleId.has(row.roleId)) {
|
||||
uniqueByRoleId.set(row.roleId, row);
|
||||
}
|
||||
}
|
||||
roleRows = Array.from(uniqueByRoleId.values());
|
||||
}
|
||||
|
||||
const parsedSudoCommands: string[] = [];
|
||||
const parsedGroupsSet = new Set<string>();
|
||||
let homedir: boolean | null = null;
|
||||
const sudoModeOrder = { none: 0, commands: 1, full: 2 };
|
||||
let sudoMode: "none" | "commands" | "full" = "none";
|
||||
for (const roleRow of roleRows) {
|
||||
try {
|
||||
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
|
||||
if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
try {
|
||||
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
||||
if (Array.isArray(grps))
|
||||
grps.forEach((g: string) => parsedGroupsSet.add(g));
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
if (roleRow?.sshCreateHomeDir === true) homedir = true;
|
||||
const m = roleRow?.sshSudoMode ?? "none";
|
||||
if (
|
||||
sudoModeOrder[m as keyof typeof sudoModeOrder] >
|
||||
sudoModeOrder[sudoMode]
|
||||
) {
|
||||
sudoMode = m as "none" | "commands" | "full";
|
||||
}
|
||||
}
|
||||
const parsedGroups = Array.from(parsedGroupsSet);
|
||||
if (homedir === null && roleRows.length > 0) {
|
||||
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
||||
}
|
||||
|
||||
// Sign the public key
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
// only valid for 5 minutes
|
||||
const validFor = 300n;
|
||||
expiresIn = Number(validFor); // seconds
|
||||
|
||||
cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
||||
keyId: `${usernameToUse}@${resource.niceId}`,
|
||||
validPrincipals: [usernameToUse, resource.niceId],
|
||||
validAfter: now - 60n, // Start 1 min ago for clock skew
|
||||
validBefore: now + validFor
|
||||
});
|
||||
|
||||
messageIds = [];
|
||||
for (const siteId of siteIds) {
|
||||
// get the site
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Site associated with resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [message] = await db
|
||||
.insert(roundTripMessageTracker)
|
||||
.values({
|
||||
wsClientId: newt.newtId,
|
||||
messageType: `newt/pam/connection`,
|
||||
sentAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!message) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create message tracker entry"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
messageIds.push(message.messageId);
|
||||
|
||||
const agentHost = siteAgentHostMap.get(siteId);
|
||||
|
||||
await sendToClient(newt.newtId, {
|
||||
type: `newt/pam/connection`,
|
||||
data: {
|
||||
messageId: message.messageId,
|
||||
orgId: orgId,
|
||||
agentPort: resource.authDaemonPort ?? 22123,
|
||||
authDaemonMode: resource.authDaemonMode, // site, remote, native where native is the pty mode
|
||||
externalAuthDaemon:
|
||||
resource.authDaemonMode === "remote", // keep this for backward compatibility but new newts are using the authDaemonMode field
|
||||
agentHost,
|
||||
caCert: caKeys.publicKeyOpenSSH,
|
||||
username: usernameToUse,
|
||||
niceId: resource.niceId,
|
||||
metadata: {
|
||||
sudoMode: sudoMode,
|
||||
sudoCommands: parsedSudoCommands,
|
||||
homedir: homedir,
|
||||
groups: parsedGroups
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (resource.pamMode === "passthrough") {
|
||||
usernameToUse = username;
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Username must be provided when PAM mode is passthrough"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Invalid PAM mode configured for resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let sshHost: string | undefined;
|
||||
if (
|
||||
resource.authDaemonMode === "site" ||
|
||||
resource.authDaemonMode === "remote"
|
||||
) {
|
||||
if (type === "private") {
|
||||
const privateResource = resource as SiteResource;
|
||||
if (privateResource.alias && privateResource.alias !== "") {
|
||||
sshHost = privateResource.alias;
|
||||
} else {
|
||||
sshHost = privateResource.destination || "";
|
||||
}
|
||||
} else {
|
||||
const publicResource = resource as Resource;
|
||||
sshHost =
|
||||
publicResource.fullDomain ||
|
||||
publicResource.subdomain ||
|
||||
publicResource.niceId;
|
||||
}
|
||||
} else if (resource.authDaemonMode === "native") {
|
||||
if (siteIds.length > 1) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Multiple sites associated with resource, unable to determine SSH host when in native mode"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// get the site
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteIds[0]))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Site associated with resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!site.address) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Site address not configured, unable to determine SSH host when in native mode"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// its the address but split off the cidr if there is one
|
||||
sshHost = site.address.split("/")[0];
|
||||
}
|
||||
|
||||
if (!sshHost) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Unable to determine SSH host for the resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await logsDb.insert(actionAuditLog).values({
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
orgId: orgId,
|
||||
actorType: "user",
|
||||
actor: req.user?.username ?? "",
|
||||
actorId: req.user?.userId ?? "",
|
||||
action: ActionsEnum.signSshKey,
|
||||
metadata: JSON.stringify({
|
||||
resourceId: normalizedResourceId,
|
||||
resourceType: type,
|
||||
resource: resource.name,
|
||||
siteIds: siteIds
|
||||
})
|
||||
});
|
||||
|
||||
await logAccessAudit({
|
||||
action: true,
|
||||
type: "ssh",
|
||||
orgId: orgId,
|
||||
resourceId:
|
||||
type === "public"
|
||||
? (resource as Resource).resourceId
|
||||
: undefined,
|
||||
siteResourceId:
|
||||
type === "private"
|
||||
? (resource as SiteResource).siteResourceId
|
||||
: undefined,
|
||||
user: req.user
|
||||
? { username: req.user.username ?? "", userId: req.user.userId }
|
||||
: undefined,
|
||||
metadata: {
|
||||
resourceName: resource.name,
|
||||
siteIds: siteIds,
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost
|
||||
},
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
|
||||
return response<SignSshKeyResponse>(res, {
|
||||
data: {
|
||||
certificate: cert?.certificate,
|
||||
messageIds: messageIds,
|
||||
messageId: messageIds[0], // just pick the first one for backward compatibility with older olms
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost, // just pick the first one for backward compatibility with older olms
|
||||
resourceId: normalizedResourceId,
|
||||
siteIds: siteIds,
|
||||
siteId: siteIds[0], // just pick the first one for backward compatibility with older olms
|
||||
keyId: cert?.keyId,
|
||||
authDaemonMode: resource.authDaemonMode,
|
||||
validPrincipals: cert?.validPrincipals,
|
||||
validAfter: cert?.validAfter.toISOString(),
|
||||
validBefore: cert?.validBefore.toISOString(),
|
||||
expiresIn
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "SSH key signed successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error signing SSH key:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred while signing the SSH key"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,22 +13,15 @@
|
||||
|
||||
import {
|
||||
handleRemoteExitNodeRegisterMessage,
|
||||
handleRemoteExitNodePingMessage,
|
||||
startRemoteExitNodeOfflineChecker,
|
||||
startExitNodeReconnectScheduler
|
||||
handleRemoteExitNodePingMessage
|
||||
} from "#private/routers/remoteExitNode";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { build } from "@server/build";
|
||||
import { handleConnectionLogMessage, handleRequestLogMessage } from "#private/routers/newt";
|
||||
import {
|
||||
handleConnectionLogMessage,
|
||||
} from "#private/routers/newt";
|
||||
|
||||
export const messageHandlers: Record<string, MessageHandler> = {
|
||||
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
|
||||
"remoteExitNode/ping": handleRemoteExitNodePingMessage,
|
||||
"newt/access-log": handleConnectionLogMessage,
|
||||
"newt/request-log": handleRequestLogMessage,
|
||||
};
|
||||
|
||||
if (build != "saas") {
|
||||
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
||||
startExitNodeReconnectScheduler(); // check pending exit node reconnects and notify newts
|
||||
}
|
||||
;
|
||||
|
||||
@@ -139,7 +139,7 @@ const processMessage = async (
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Message handling error:", error);
|
||||
logger.warn("Message handling error:", error);
|
||||
// ws.send(JSON.stringify({
|
||||
// type: "error",
|
||||
// data: {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { build } from "@server/build";
|
||||
import { startRemoteExitNodeOfflineChecker } from "./routers/remoteExitNode";
|
||||
import { startExitNodeReconnectScheduler } from "./routers/remoteExitNode/exitNodeReconnectScheduler";
|
||||
import { startSchedulers as ossStartSchedulers } from "@server/startSchedulers";
|
||||
|
||||
export function startSchedulers() {
|
||||
if (build != "saas") {
|
||||
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
||||
startExitNodeReconnectScheduler(); // check pending exit node reconnects and notify newts
|
||||
}
|
||||
ossStartSchedulers();
|
||||
}
|
||||
Reference in New Issue
Block a user