Merge branch 'dev' into user-compliance

This commit is contained in:
Owen
2025-10-27 10:37:53 -07:00
105 changed files with 8762 additions and 776 deletions

View File

@@ -81,6 +81,9 @@ export enum ActionsEnum {
listClients = "listClients",
getClient = "getClient",
listOrgDomains = "listOrgDomains",
getDomain = "getDomain",
updateOrgDomain = "updateOrgDomain",
getDNSRecords = "getDNSRecords",
createNewt = "createNewt",
createIdp = "createIdp",
updateIdp = "updateIdp",
@@ -116,7 +119,9 @@ export enum ActionsEnum {
updateLoginPage = "updateLoginPage",
getLoginPage = "getLoginPage",
deleteLoginPage = "deleteLoginPage",
applyBlueprint = "applyBlueprint"
applyBlueprint = "applyBlueprint",
viewLogs = "viewLogs",
exportLogs = "exportLogs"
}
export async function checkUserActionPermission(

View File

@@ -6,7 +6,8 @@ import {
integer,
bigint,
real,
text
text,
index
} from "drizzle-orm/pg-core";
import { InferSelectModel } from "drizzle-orm";
import { domains, orgs, targets, users, exitNodes, sessions } from "./schema";
@@ -213,6 +214,43 @@ export const sessionTransferToken = pgTable("sessionTransferToken", {
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
});
export const actionAuditLog = pgTable("actionAuditLog", {
id: serial("id").primaryKey(),
timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
actorType: varchar("actorType", { length: 50 }).notNull(),
actor: varchar("actor", { length: 255 }).notNull(),
actorId: varchar("actorId", { length: 255 }).notNull(),
action: varchar("action", { length: 100 }).notNull(),
metadata: text("metadata")
}, (table) => ([
index("idx_actionAuditLog_timestamp").on(table.timestamp),
index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp)
]));
export const accessAuditLog = pgTable("accessAuditLog", {
id: serial("id").primaryKey(),
timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
actorType: varchar("actorType", { length: 50 }),
actor: varchar("actor", { length: 255 }),
actorId: varchar("actorId", { length: 255 }),
resourceId: integer("resourceId"),
ip: varchar("ip", { length: 45 }),
type: varchar("type", { length: 100 }).notNull(),
action: boolean("action").notNull(),
location: text("location"),
userAgent: text("userAgent"),
metadata: text("metadata")
}, (table) => ([
index("idx_identityAuditLog_timestamp").on(table.timestamp),
index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp)
]));
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
export type Certificate = InferSelectModel<typeof certificates>;
@@ -230,3 +268,5 @@ export type RemoteExitNodeSession = InferSelectModel<
>;
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
export type LoginPage = InferSelectModel<typeof loginPage>;
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;

View File

@@ -6,7 +6,8 @@ import {
integer,
bigint,
real,
text
text,
index
} from "drizzle-orm/pg-core";
import { InferSelectModel } from "drizzle-orm";
import { randomUUID } from "crypto";
@@ -18,7 +19,22 @@ export const domains = pgTable("domains", {
type: varchar("type"), // "ns", "cname", "wildcard"
verified: boolean("verified").notNull().default(false),
failed: boolean("failed").notNull().default(false),
tries: integer("tries").notNull().default(0)
tries: integer("tries").notNull().default(0),
certResolver: varchar("certResolver"),
customCertResolver: varchar("customCertResolver"),
preferWildcardCert: boolean("preferWildcardCert")
});
export const dnsRecords = pgTable("dnsRecords", {
id: varchar("id").primaryKey(),
domainId: varchar("domainId")
.notNull()
.references(() => domains.domainId, { onDelete: "cascade" }),
recordType: varchar("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT"
baseDomain: varchar("baseDomain"),
value: varchar("value").notNull(),
verified: boolean("verified").notNull().default(false),
});
export const orgs = pgTable("orgs", {
@@ -28,7 +44,16 @@ export const orgs = pgTable("orgs", {
createdAt: text("createdAt"),
requireTwoFactor: boolean("requireTwoFactor"),
maxSessionLengthHours: integer("maxSessionLengthHours"),
passwordExpiryDays: integer("passwordExpiryDays")
passwordExpiryDays: integer("passwordExpiryDays"),
settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever
.notNull()
.default(7),
settingsLogRetentionDaysAccess: integer("settingsLogRetentionDaysAccess")
.notNull()
.default(0),
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction")
.notNull()
.default(0)
});
export const orgDomains = pgTable("orgDomains", {
@@ -102,9 +127,11 @@ export const resources = pgTable("resources", {
setHostHeader: varchar("setHostHeader"),
enableProxy: boolean("enableProxy").default(true),
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "cascade"
onDelete: "set null"
}),
headers: text("headers") // comma-separated list of headers to add to the request
headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1)
});
export const targets = pgTable("targets", {
@@ -676,6 +703,42 @@ export const setupTokens = pgTable("setupTokens", {
dateUsed: varchar("dateUsed")
});
export const requestAuditLog = pgTable(
"requestAuditLog",
{
id: serial("id").primaryKey(),
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
orgId: text("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
action: boolean("action").notNull(),
reason: integer("reason").notNull(),
actorType: text("actorType"),
actor: text("actor"),
actorId: text("actorId"),
resourceId: integer("resourceId"),
ip: text("ip"),
location: text("location"),
userAgent: text("userAgent"),
metadata: text("metadata"),
headers: text("headers"), // JSON blob
query: text("query"), // JSON blob
originalRequestURL: text("originalRequestURL"),
scheme: text("scheme"),
host: text("host"),
path: text("path"),
method: text("method"),
tls: boolean("tls")
},
(table) => [
index("idx_requestAuditLog_timestamp").on(table.timestamp),
index("idx_requestAuditLog_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -727,3 +790,7 @@ export type SetupToken = InferSelectModel<typeof setupTokens>;
export type HostMeta = InferSelectModel<typeof hostMeta>;
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
export type IdpOidcConfig = InferSelectModel<typeof idpOidcConfig>;
export type LicenseKey = InferSelectModel<typeof licenseKey>;
export type SecurityKey = InferSelectModel<typeof securityKeys>;
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;

View File

@@ -2,10 +2,12 @@ import {
sqliteTable,
integer,
text,
real
real,
index
} from "drizzle-orm/sqlite-core";
import { InferSelectModel } from "drizzle-orm";
import { domains, orgs, targets, users, exitNodes, sessions } from "./schema";
import { metadata } from "@app/app/[orgId]/settings/layout";
export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -207,6 +209,43 @@ export const sessionTransferToken = sqliteTable("sessionTransferToken", {
expiresAt: integer("expiresAt").notNull()
});
export const actionAuditLog = sqliteTable("actionAuditLog", {
id: integer("id").primaryKey({ autoIncrement: true }),
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
actorType: text("actorType").notNull(),
actor: text("actor").notNull(),
actorId: text("actorId").notNull(),
action: text("action").notNull(),
metadata: text("metadata")
}, (table) => ([
index("idx_actionAuditLog_timestamp").on(table.timestamp),
index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp)
]));
export const accessAuditLog = sqliteTable("accessAuditLog", {
id: integer("id").primaryKey({ autoIncrement: true }),
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
actorType: text("actorType"),
actor: text("actor"),
actorId: text("actorId"),
resourceId: integer("resourceId"),
ip: text("ip"),
location: text("location"),
type: text("type").notNull(),
action: integer("action", { mode: "boolean" }).notNull(),
userAgent: text("userAgent"),
metadata: text("metadata")
}, (table) => ([
index("idx_identityAuditLog_timestamp").on(table.timestamp),
index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp)
]));
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
export type Certificate = InferSelectModel<typeof certificates>;
@@ -224,3 +263,5 @@ export type RemoteExitNodeSession = InferSelectModel<
>;
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
export type LoginPage = InferSelectModel<typeof loginPage>;
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "crypto";
import { InferSelectModel } from "drizzle-orm";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
import { boolean } from "yargs";
export const domains = sqliteTable("domains", {
domainId: text("domainId").primaryKey(),
@@ -11,9 +12,24 @@ export const domains = sqliteTable("domains", {
type: text("type"), // "ns", "cname", "wildcard"
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
tries: integer("tries").notNull().default(0)
tries: integer("tries").notNull().default(0),
certResolver: text("certResolver"),
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" })
});
export const dnsRecords = sqliteTable("dnsRecords", {
id: text("id").primaryKey(),
domainId: text("domainId")
.notNull()
.references(() => domains.domainId, { onDelete: "cascade" }),
recordType: text("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT"
baseDomain: text("baseDomain"),
value: text("value").notNull(),
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
});
export const orgs = sqliteTable("orgs", {
orgId: text("orgId").primaryKey(),
name: text("name").notNull(),
@@ -21,7 +37,16 @@ export const orgs = sqliteTable("orgs", {
createdAt: text("createdAt"),
requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }),
maxSessionLengthHours: integer("maxSessionLengthHours"), // hours
passwordExpiryDays: integer("passwordExpiryDays") // days
passwordExpiryDays: integer("passwordExpiryDays"), // days
settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever
.notNull()
.default(7),
settingsLogRetentionDaysAccess: integer("settingsLogRetentionDaysAccess")
.notNull()
.default(0),
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction")
.notNull()
.default(0)
});
export const userDomains = sqliteTable("userDomains", {
@@ -114,9 +139,12 @@ export const resources = sqliteTable("resources", {
setHostHeader: text("setHostHeader"),
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "cascade"
onDelete: "set null"
}),
headers: text("headers") // comma-separated list of headers to add to the request
headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: integer("proxyProtocol", { mode: "boolean" }).notNull().default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1)
});
export const targets = sqliteTable("targets", {
@@ -721,6 +749,42 @@ export const idpOrg = sqliteTable("idpOrg", {
orgMapping: text("orgMapping")
});
export const requestAuditLog = sqliteTable(
"requestAuditLog",
{
id: integer("id").primaryKey({ autoIncrement: true }),
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
orgId: text("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
action: integer("action", { mode: "boolean" }).notNull(),
reason: integer("reason").notNull(),
actorType: text("actorType"),
actor: text("actor"),
actorId: text("actorId"),
resourceId: integer("resourceId"),
ip: text("ip"),
location: text("location"),
userAgent: text("userAgent"),
metadata: text("metadata"),
headers: text("headers"), // JSON blob
query: text("query"), // JSON blob
originalRequestURL: text("originalRequestURL"),
scheme: text("scheme"),
host: text("host"),
path: text("path"),
method: text("method"),
tls: integer("tls", { mode: "boolean" })
},
(table) => [
index("idx_requestAuditLog_timestamp").on(table.timestamp),
index("idx_requestAuditLog_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -757,6 +821,7 @@ export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>;
export type DnsRecord = InferSelectModel<typeof dnsRecords>;
export type Client = InferSelectModel<typeof clients>;
export type ClientSite = InferSelectModel<typeof clientSites>;
export type RoleClient = InferSelectModel<typeof roleClients>;
@@ -772,3 +837,7 @@ export type SetupToken = InferSelectModel<typeof setupTokens>;
export type HostMeta = InferSelectModel<typeof hostMeta>;
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
export type IdpOidcConfig = InferSelectModel<typeof idpOidcConfig>;
export type LicenseKey = InferSelectModel<typeof licenseKey>;
export type SecurityKey = InferSelectModel<typeof securityKeys>;
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;

View File

@@ -5,6 +5,7 @@ import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer";
import { createNextServer } from "./nextServer";
import { createInternalServer } from "./internalServer";
import { createIntegrationApiServer } from "./integrationApiServer";
import {
ApiKey,
ApiKeyOrg,
@@ -13,13 +14,14 @@ import {
User,
UserOrg
} from "@server/db";
import { createIntegrationApiServer } from "./integrationApiServer";
import config from "@server/lib/config";
import { setHostMeta } from "@server/lib/hostMeta";
import { initTelemetryClient } from "./lib/telemetry.js";
import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js";
import { initTelemetryClient } from "@server/lib/telemetry";
import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
import { initCleanup } from "#dynamic/cleanup";
import license from "#dynamic/license/license";
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
import { fetchServerIp } from "@server/lib/serverIpService";
async function startServers() {
await setHostMeta();
@@ -31,14 +33,17 @@ async function startServers() {
await runSetupFunctions();
await fetchServerIp();
initTelemetryClient();
initLogCleanupInterval();
// Start all servers
const apiServer = createApiServer();
const internalServer = createInternalServer();
let nextServer;
nextServer = await createNextServer();
const nextServer = await createNextServer();
if (config.getRawConfig().traefik.file_mode) {
const monitor = new TraefikConfigManager();
await monitor.start();

View File

@@ -1,8 +1,8 @@
export async function getOrgTierData(
orgId: string
): Promise<{ tier: string | null; active: boolean }> {
let tier = null;
let active = false;
const tier = null;
const active = false;
return { tier, active };
}

View File

@@ -1,5 +1,4 @@
import { eq, sql, and } from "drizzle-orm";
import NodeCache from "node-cache";
import { v4 as uuidv4 } from "uuid";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import * as fs from "fs/promises";
@@ -20,6 +19,7 @@ import logger from "@server/logger";
import { sendToClient } from "#dynamic/routers/ws";
import { build } from "@server/build";
import { s3Client } from "@server/lib/s3";
import cache from "@server/lib/cache";
interface StripeEvent {
identifier?: string;
@@ -43,7 +43,6 @@ export function noop() {
}
export class UsageService {
private cache: NodeCache;
private bucketName: string | undefined;
private currentEventFile: string | null = null;
private currentFileStartTime: number = 0;
@@ -51,7 +50,6 @@ export class UsageService {
private uploadingFiles: Set<string> = new Set();
constructor() {
this.cache = new NodeCache({ stdTTL: 300 }); // 5 minute TTL
if (noop()) {
return;
}
@@ -399,7 +397,7 @@ export class UsageService {
featureId: FeatureId
): Promise<string | null> {
const cacheKey = `customer_${orgId}_${featureId}`;
const cached = this.cache.get<string>(cacheKey);
const cached = cache.get<string>(cacheKey);
if (cached) {
return cached;
@@ -422,7 +420,7 @@ export class UsageService {
const customerId = customer.customerId;
// Cache the result
this.cache.set(cacheKey, customerId);
cache.set(cacheKey, customerId, 300); // 5 minute TTL
return customerId;
} catch (error) {
@@ -700,10 +698,6 @@ export class UsageService {
await this.uploadFileToS3();
}
public clearCache(): void {
this.cache.flushAll();
}
/**
* Scan the events directory for files older than 1 minute and upload them if not empty.
*/

View File

@@ -527,7 +527,7 @@ export async function updateProxyResources(
if (
existingRule.action !== getRuleAction(rule.action) ||
existingRule.match !== rule.match.toUpperCase() ||
existingRule.value !== rule.value
existingRule.value !== rule.value.toUpperCase()
) {
validateRule(rule);
await trx
@@ -535,7 +535,7 @@ export async function updateProxyResources(
.set({
action: getRuleAction(rule.action),
match: rule.match.toUpperCase(),
value: rule.value
value: rule.value.toUpperCase(),
})
.where(
eq(resourceRules.ruleId, existingRule.ruleId)
@@ -547,7 +547,7 @@ export async function updateProxyResources(
resourceId: existingResource.resourceId,
action: getRuleAction(rule.action),
match: rule.match.toUpperCase(),
value: rule.value,
value: rule.value.toUpperCase(),
priority: index + 1 // start priorities at 1
});
}
@@ -705,7 +705,7 @@ export async function updateProxyResources(
resourceId: newResource.resourceId,
action: getRuleAction(rule.action),
match: rule.match.toUpperCase(),
value: rule.value,
value: rule.value.toUpperCase(),
priority: index + 1 // start priorities at 1
});
}

View File

@@ -275,24 +275,26 @@ export const ConfigSchema = z
}
)
.refine(
// Enforce proxy-port uniqueness within proxy-resources
// Enforce proxy-port uniqueness within proxy-resources per protocol
(config) => {
const proxyPortMap = new Map<number, string[]>();
const protocolPortMap = new Map<string, string[]>();
Object.entries(config["proxy-resources"]).forEach(
([resourceKey, resource]) => {
const proxyPort = resource["proxy-port"];
if (proxyPort !== undefined) {
if (!proxyPortMap.has(proxyPort)) {
proxyPortMap.set(proxyPort, []);
const protocol = resource.protocol;
if (proxyPort !== undefined && protocol !== undefined) {
const key = `${protocol}:${proxyPort}`;
if (!protocolPortMap.has(key)) {
protocolPortMap.set(key, []);
}
proxyPortMap.get(proxyPort)!.push(resourceKey);
protocolPortMap.get(key)!.push(resourceKey);
}
}
);
// Find duplicates
const duplicates = Array.from(proxyPortMap.entries()).filter(
const duplicates = Array.from(protocolPortMap.entries()).filter(
([_, resourceKeys]) => resourceKeys.length > 1
);
@@ -300,25 +302,29 @@ export const ConfigSchema = z
},
(config) => {
// Extract duplicates for error message
const proxyPortMap = new Map<number, string[]>();
const protocolPortMap = new Map<string, string[]>();
Object.entries(config["proxy-resources"]).forEach(
([resourceKey, resource]) => {
const proxyPort = resource["proxy-port"];
if (proxyPort !== undefined) {
if (!proxyPortMap.has(proxyPort)) {
proxyPortMap.set(proxyPort, []);
const protocol = resource.protocol;
if (proxyPort !== undefined && protocol !== undefined) {
const key = `${protocol}:${proxyPort}`;
if (!protocolPortMap.has(key)) {
protocolPortMap.set(key, []);
}
proxyPortMap.get(proxyPort)!.push(resourceKey);
protocolPortMap.get(key)!.push(resourceKey);
}
}
);
const duplicates = Array.from(proxyPortMap.entries())
const duplicates = Array.from(protocolPortMap.entries())
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
.map(
([proxyPort, resourceKeys]) =>
`port ${proxyPort} used by proxy-resources: ${resourceKeys.join(", ")}`
([protocolPort, resourceKeys]) => {
const [protocol, port] = protocolPort.split(':');
return `${protocol.toUpperCase()} port ${port} used by proxy-resources: ${resourceKeys.join(", ")}`;
}
)
.join("; ");

5
server/lib/cache.ts Normal file
View File

@@ -0,0 +1,5 @@
import NodeCache from "node-cache";
export const cache = new NodeCache({ stdTTL: 3600, checkperiod: 120 });
export default cache;

62
server/lib/cleanupLogs.ts Normal file
View File

@@ -0,0 +1,62 @@
import { db, orgs } from "@server/db";
import { cleanUpOldLogs as cleanUpOldAccessLogs } from "@server/private/lib/logAccessAudit";
import { cleanUpOldLogs as cleanUpOldActionLogs } from "@server/private/middlewares/logActionAudit";
import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit";
import { gt, or } from "drizzle-orm";
export function initLogCleanupInterval() {
return setInterval(
async () => {
const orgsToClean = await db
.select({
orgId: orgs.orgId,
settingsLogRetentionDaysAction:
orgs.settingsLogRetentionDaysAction,
settingsLogRetentionDaysAccess:
orgs.settingsLogRetentionDaysAccess,
settingsLogRetentionDaysRequest:
orgs.settingsLogRetentionDaysRequest
})
.from(orgs)
.where(
or(
gt(orgs.settingsLogRetentionDaysAction, 0),
gt(orgs.settingsLogRetentionDaysAccess, 0),
gt(orgs.settingsLogRetentionDaysRequest, 0)
)
);
for (const org of orgsToClean) {
const {
orgId,
settingsLogRetentionDaysAction,
settingsLogRetentionDaysAccess,
settingsLogRetentionDaysRequest
} = org;
if (settingsLogRetentionDaysAction > 0) {
await cleanUpOldActionLogs(
orgId,
settingsLogRetentionDaysRequest
);
}
if (settingsLogRetentionDaysAccess > 0) {
await cleanUpOldAccessLogs(
orgId,
settingsLogRetentionDaysRequest
);
}
if (settingsLogRetentionDaysRequest > 0) {
await cleanUpOldRequestLogs(
orgId,
settingsLogRetentionDaysRequest
);
}
}
},
// 3 * 60 * 60 * 1000
60 * 1000 // for testing
); // every 3 hours
}

View File

@@ -6,7 +6,7 @@ export async function getCountryCodeForIp(
): Promise<string | undefined> {
try {
if (!maxmindLookup) {
logger.warn(
logger.debug(
"MaxMind DB path not configured, cannot perform GeoIP lookup"
);
return;

View File

@@ -0,0 +1,28 @@
import axios from "axios";
let serverIp: string | null = null;
const services = [
"https://ifconfig.io/ip",
"https://api.ipify.org",
"https://checkip.amazonaws.com"
];
export async function fetchServerIp() {
for (const url of services) {
try {
const response = await axios.get(url, { timeout: 5000 });
serverIp = response.data.trim();
console.log("Detected public IP:", serverIp);
return;
} catch (err: any) {
console.warn(`Failed to fetch server IP from ${url}: ${err.message || err.code}`);
}
}
console.error("All attempts to fetch server IP failed.");
}
export function getServerIp() {
return serverIp;
}

View File

@@ -309,10 +309,7 @@ export class TraefikConfigManager {
this.lastActiveDomains = new Set(domains);
}
if (
process.env.USE_PANGOLIN_DNS === "true" &&
build != "oss"
) {
if (process.env.USE_PANGOLIN_DNS === "true" && build != "oss") {
// Scan current local certificate state
this.lastLocalCertificateState =
await this.scanLocalCertificateState();
@@ -450,7 +447,8 @@ export class TraefikConfigManager {
currentExitNode,
config.getRawConfig().traefik.site_types,
build == "oss", // filter out the namespace domains in open source
build != "oss" // generate the login pages on the cloud and hybrid
build != "oss", // generate the login pages on the cloud and hybrid,
build == "saas" ? false : config.getRawConfig().traefik.allow_raw_resources // dont allow raw resources on saas otherwise use config
);
const domains = new Set<string>();
@@ -502,6 +500,25 @@ export class TraefikConfigManager {
};
}
// tcp:
// serversTransports:
// pp-transport-v1:
// proxyProtocol:
// version: 1
// pp-transport-v2:
// proxyProtocol:
// version: 2
if (build != "saas") {
// add the serversTransports section if not present
if (traefikConfig.tcp && !traefikConfig.tcp.serversTransports) {
traefikConfig.tcp.serversTransports = {
"pp-transport-v1": { proxyProtocol: { version: 1 } },
"pp-transport-v2": { proxyProtocol: { version: 2 } }
};
}
}
return { domains, traefikConfig };
} catch (error) {
// pull data out of the axios error to log

View File

@@ -1,4 +1,4 @@
import { db, targetHealthCheck } from "@server/db";
import { db, targetHealthCheck, domains } from "@server/db";
import {
and,
eq,
@@ -23,7 +23,8 @@ export async function getTraefikConfig(
exitNodeId: number,
siteTypes: string[],
filterOutNamespaceDomains = false,
generateLoginPageRouters = false
generateLoginPageRouters = false,
allowRawResources = true
): Promise<any> {
// Define extended target type with site information
type TargetWithSite = Target & {
@@ -56,6 +57,8 @@ export async function getTraefikConfig(
setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy,
headers: resources.headers,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
// Target fields
targetId: targets.targetId,
targetEnabled: targets.enabled,
@@ -75,11 +78,14 @@ export async function getTraefikConfig(
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
exitNodeId: sites.exitNodeId
exitNodeId: sites.exitNodeId,
// Domain cert resolver fields
domainCertResolver: domains.certResolver
})
.from(sites)
.innerJoin(targets, eq(targets.siteId, sites.siteId))
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
@@ -101,7 +107,7 @@ export async function getTraefikConfig(
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record
),
inArray(sites.type, siteTypes),
config.getRawConfig().traefik.allow_raw_resources
allowRawResources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true)
)
@@ -164,11 +170,15 @@ export async function getTraefikConfig(
enableProxy: row.enableProxy,
targets: [],
headers: row.headers,
proxyProtocol: row.proxyProtocol,
proxyProtocolVersion: row.proxyProtocolVersion ?? 1,
path: row.path, // the targets will all have the same path
pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType
rewritePath: row.rewritePath,
rewritePathType: row.rewritePathType,
priority: priority // may be null, we fallback later
priority: priority,
// Store domain cert resolver fields
domainCertResolver: row.domainCertResolver
});
}
@@ -247,30 +257,45 @@ export async function getTraefikConfig(
wildCard = resource.fullDomain;
}
const configDomain = config.getDomain(resource.domainId);
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
let certResolver: string, preferWildcardCert: boolean;
if (!configDomain) {
certResolver = config.getRawConfig().traefik.cert_resolver;
preferWildcardCert =
config.getRawConfig().traefik.prefer_wildcard_cert;
} else {
certResolver = configDomain.cert_resolver;
preferWildcardCert = configDomain.prefer_wildcard_cert;
}
const domainCertResolver = resource.domainCertResolver;
const preferWildcardCert = resource.preferWildcardCert;
const tls = {
certResolver: certResolver,
...(preferWildcardCert
? {
domains: [
{
main: wildCard
}
]
}
: {})
};
let resolverName: string | undefined;
let preferWildcard: boolean | undefined;
// Handle both letsencrypt & custom cases
if (domainCertResolver) {
resolverName = domainCertResolver.trim();
} else {
resolverName = globalDefaultResolver;
}
if (
preferWildcardCert !== undefined &&
preferWildcardCert !== null
) {
preferWildcard = preferWildcardCert;
} else {
preferWildcard = globalDefaultPreferWildcard;
}
const tls = {
certResolver: resolverName,
...(preferWildcard
? {
domains: [
{
main: wildCard
}
]
}
: {})
};
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
@@ -509,14 +534,14 @@ export async function getTraefikConfig(
})(),
...(resource.stickySession
? {
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
: {})
}
};
@@ -615,15 +640,20 @@ export async function getTraefikConfig(
}
});
})(),
...(resource.proxyProtocol && protocol == "tcp"
? {
serversTransport: `pp-transport-v${resource.proxyProtocolVersion || 1}`
}
: {}),
...(resource.stickySession
? {
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
: {})
}
};

View File

@@ -27,3 +27,4 @@ export * from "./verifyDomainAccess";
export * from "./verifyClientsEnabled";
export * from "./verifyUserIsOrgOwner";
export * from "./verifySiteResourceAccess";
export * from "./logActionAudit";

View File

@@ -0,0 +1,12 @@
import { ActionsEnum } from "@server/auth/actions";
import { Request, Response, NextFunction } from "express";
export function logActionAudit(action: ActionsEnum) {
return async function (
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
next();
};
}

View File

@@ -16,8 +16,8 @@ import { certificates, db } from "@server/db";
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
import { decryptData } from "@server/lib/encryption";
import * as fs from "fs";
import NodeCache from "node-cache";
import logger from "@server/logger";
import cache from "@server/lib/cache";
let encryptionKeyPath = "";
let encryptionKeyHex = "";
@@ -51,9 +51,6 @@ export type CertificateResult = {
updatedAt?: number | null;
};
// --- In-Memory Cache Implementation ---
const certificateCache = new NodeCache({ stdTTL: 180 }); // Cache for 3 minutes (180 seconds)
export async function getValidCertificatesForDomains(
domains: Set<string>,
useCache: boolean = true
@@ -67,7 +64,8 @@ export async function getValidCertificatesForDomains(
// 1. Check cache first if enabled
if (useCache) {
for (const domain of domains) {
const cachedCert = certificateCache.get<CertificateResult>(domain);
const cacheKey = `cert:${domain}`;
const cachedCert = cache.get<CertificateResult>(cacheKey);
if (cachedCert) {
finalResults.push(cachedCert); // Valid cache hit
} else {
@@ -180,7 +178,8 @@ export async function getValidCertificatesForDomains(
// Add to cache for future requests, using the *requested domain* as the key
if (useCache) {
certificateCache.set(domain, resultCert);
const cacheKey = `cert:${domain}`;
cache.set(cacheKey, resultCert, 180);
}
}
}

View File

@@ -0,0 +1,157 @@
import { accessAuditLog, db, orgs } from "@server/db";
import { getCountryCodeForIp } from "@server/lib/geoip";
import logger from "@server/logger";
import { and, eq, lt } from "drizzle-orm";
import cache from "@server/lib/cache";
async function getAccessDays(orgId: string): Promise<number> {
// check cache first
const cached = cache.get<number>(`org_${orgId}_accessDays`);
if (cached !== undefined) {
return cached;
}
const [org] = await db
.select({
settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction
})
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return 0;
}
// store the result in cache
cache.set(
`org_${orgId}_accessDays`,
org.settingsLogRetentionDaysAction,
300
);
return org.settingsLogRetentionDaysAction;
}
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
const now = Math.floor(Date.now() / 1000);
const cutoffTimestamp = now - retentionDays * 24 * 60 * 60;
try {
const deleteResult = await db
.delete(accessAuditLog)
.where(
and(
lt(accessAuditLog.timestamp, cutoffTimestamp),
eq(accessAuditLog.orgId, orgId)
)
);
logger.info(
`Cleaned up ${deleteResult.changes} access audit logs older than ${retentionDays} days`
);
} catch (error) {
logger.error("Error cleaning up old action audit logs:", error);
}
}
export async function logAccessAudit(data: {
action: boolean;
type: string;
orgId: string;
resourceId?: number;
user?: { username: string; userId: string };
apiKey?: { name: string | null; apiKeyId: string };
metadata?: any;
userAgent?: string;
requestIp?: string;
}) {
try {
const retentionDays = await getAccessDays(data.orgId);
if (retentionDays === 0) {
// do not log
return;
}
let actorType: string | undefined;
let actor: string | undefined;
let actorId: string | undefined;
const user = data.user;
if (user) {
actorType = "user";
actor = user.username;
actorId = user.userId;
}
const apiKey = data.apiKey;
if (apiKey) {
actorType = "apiKey";
actor = apiKey.name || apiKey.apiKeyId;
actorId = apiKey.apiKeyId;
}
// if (!actorType || !actor || !actorId) {
// logger.warn("logRequestAudit: Incomplete actor information");
// return;
// }
const timestamp = Math.floor(Date.now() / 1000);
let metadata = null;
if (metadata) {
metadata = JSON.stringify(metadata);
}
const clientIp = data.requestIp
? (() => {
if (
data.requestIp.startsWith("[") &&
data.requestIp.includes("]")
) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = data.requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
return data.requestIp;
})()
: undefined;
const countryCode = data.requestIp
? await getCountryCodeFromIp(data.requestIp)
: undefined;
await db.insert(accessAuditLog).values({
timestamp: timestamp,
orgId: data.orgId,
actorType,
actor,
actorId,
action: data.action,
type: data.type,
metadata,
resourceId: data.resourceId,
userAgent: data.userAgent,
ip: clientIp,
location: countryCode
});
} catch (error) {
logger.error(error);
}
}
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
const geoIpCacheKey = `geoip_access:${ip}`;
let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
if (!cachedCountryCode) {
cachedCountryCode = await getCountryCodeForIp(ip); // do it locally
// Cache for longer since IP geolocation doesn't change frequently
cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
}
return cachedCountryCode;
}

View File

@@ -15,6 +15,7 @@ import {
certificates,
db,
domainNamespaces,
domains,
exitNodes,
loginPage,
targetHealthCheck
@@ -50,7 +51,8 @@ export async function getTraefikConfig(
exitNodeId: number,
siteTypes: string[],
filterOutNamespaceDomains = false,
generateLoginPageRouters = false
generateLoginPageRouters = false,
allowRawResources = true
): Promise<any> {
// Define extended target type with site information
type TargetWithSite = Target & {
@@ -104,11 +106,16 @@ export async function getTraefikConfig(
subnet: sites.subnet,
exitNodeId: sites.exitNodeId,
// Namespace
domainNamespaceId: domainNamespaces.domainNamespaceId
domainNamespaceId: domainNamespaces.domainNamespaceId,
// Certificate
certificateStatus: certificates.status,
domainCertResolver: domains.certResolver,
})
.from(sites)
.innerJoin(targets, eq(targets.siteId, sites.siteId))
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
.leftJoin(certificates, eq(certificates.domainId, resources.domainId))
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
@@ -135,7 +142,7 @@ export async function getTraefikConfig(
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record
),
inArray(sites.type, siteTypes),
config.getRawConfig().traefik.allow_raw_resources
allowRawResources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true)
)
@@ -206,7 +213,8 @@ export async function getTraefikConfig(
pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType
rewritePath: row.rewritePath,
rewritePathType: row.rewritePathType,
priority: priority // may be null, we fallback later
priority: priority, // may be null, we fallback later
domainCertResolver: row.domainCertResolver,
});
}
@@ -294,6 +302,20 @@ export async function getTraefikConfig(
config_output.http.services = {};
}
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!resource.subdomain) {
wildCard = resource.fullDomain;
}
const configDomain = config.getDomain(resource.domainId);
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
@@ -324,13 +346,13 @@ export async function getTraefikConfig(
certResolver: certResolver,
...(preferWildcardCert
? {
domains: [
{
main: wildCard
}
]
}
: {})
domains: [
{
main: wildCard,
},
],
}
: {}),
};
} else {
// find a cert that matches the full domain, if not continue
@@ -582,14 +604,14 @@ export async function getTraefikConfig(
})(),
...(resource.stickySession
? {
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
: {})
}
};
@@ -688,15 +710,20 @@ export async function getTraefikConfig(
}
});
})(),
...(resource.proxyProtocol && protocol == "tcp" // proxy protocol only works for tcp
? {
serversTransport: `pp-transport-v${resource.proxyProtocolVersion || 1}`
}
: {}),
...(resource.stickySession
? {
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
: {})
}
};
@@ -744,10 +771,9 @@ export async function getTraefikConfig(
loadBalancer: {
servers: [
{
url: `http://${
config.getRawConfig().server
url: `http://${config.getRawConfig().server
.internal_hostname
}:${config.getRawConfig().server.next_port}`
}:${config.getRawConfig().server.next_port}`
}
]
}
@@ -763,7 +789,7 @@ export async function getTraefikConfig(
continue;
}
let tls = {};
const tls = {};
if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
) {

View File

@@ -15,4 +15,5 @@ export * from "./verifyCertificateAccess";
export * from "./verifyRemoteExitNodeAccess";
export * from "./verifyIdpAccess";
export * from "./verifyLoginPageAccess";
export * from "../../lib/corsWithLoginPage";
export * from "./logActionAudit";
export * from "./verifySubscription";

View File

@@ -0,0 +1,145 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 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 { ActionsEnum } from "@server/auth/actions";
import { actionAuditLog, db, orgs } from "@server/db";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import { and, eq, lt } from "drizzle-orm";
import cache from "@server/lib/cache";
async function getActionDays(orgId: string): Promise<number> {
// check cache first
const cached = cache.get<number>(`org_${orgId}_actionDays`);
if (cached !== undefined) {
return cached;
}
const [org] = await db
.select({
settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction
})
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return 0;
}
// store the result in cache
cache.set(`org_${orgId}_actionDays`, org.settingsLogRetentionDaysAction, 300);
return org.settingsLogRetentionDaysAction;
}
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
const now = Math.floor(Date.now() / 1000);
const cutoffTimestamp = now - retentionDays * 24 * 60 * 60;
try {
const deleteResult = await db
.delete(actionAuditLog)
.where(
and(
lt(actionAuditLog.timestamp, cutoffTimestamp),
eq(actionAuditLog.orgId, orgId)
)
);
logger.info(
`Cleaned up ${deleteResult.changes} action audit logs older than ${retentionDays} days`
);
} catch (error) {
logger.error("Error cleaning up old action audit logs:", error);
}
}
export function logActionAudit(action: ActionsEnum) {
return async function (
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
let orgId;
let actorType;
let actor;
let actorId;
const user = req.user;
if (user) {
const userOrg = req.userOrg;
orgId = userOrg?.orgId;
actorType = "user";
actor = user.username;
actorId = user.userId;
}
const apiKey = req.apiKey;
if (apiKey) {
const apiKeyOrg = req.apiKeyOrg;
orgId = apiKeyOrg?.orgId;
actorType = "apiKey";
actor = apiKey.name;
actorId = apiKey.apiKeyId;
}
if (!orgId) {
logger.warn("logActionAudit: No organization context found");
return next();
}
if (!actorType || !actor || !actorId) {
logger.warn("logActionAudit: Incomplete actor information");
return next();
}
const retentionDays = await getActionDays(orgId);
if (retentionDays === 0) {
// do not log
return next();
}
const timestamp = Math.floor(Date.now() / 1000);
let metadata = null;
if (req.params) {
metadata = JSON.stringify(req.params);
}
await db.insert(actionAuditLog).values({
timestamp,
orgId,
actorType,
actor,
actorId,
action,
metadata
});
return next();
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying logging action"
)
);
}
};
}

View File

@@ -0,0 +1,50 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 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 createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { build } from "@server/build";
import { getOrgTierData } from "#private/lib/billing";
export async function verifyValidSubscription(
req: Request,
res: Response,
next: NextFunction
) {
try {
if (build != "saas") {
return next();
}
const tier = await getOrgTierData(req.params.orgId);
if (!tier.active) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Organization does not have an active subscription"
)
);
}
return next();
} catch (e) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying subscription"
)
);
}
}

View File

@@ -0,0 +1,81 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 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 { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { OpenAPITags } from "@server/openApi";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { queryAccessAuditLogsParams, queryAccessAuditLogsQuery, queryAccess } from "./queryAccessAuditLog";
import { generateCSV } from "@server/routers/auditLogs/generateCSV";
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/access/export",
description: "Export the access audit log for an organization as CSV",
tags: [OpenAPITags.Org],
request: {
query: queryAccessAuditLogsQuery,
params: queryAccessAuditLogsParams
},
responses: {}
});
export async function exportAccessAuditLogs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = queryAccessAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const data = { ...parsedQuery.data, ...parsedParams.data };
const baseQuery = queryAccess(data);
const log = await baseQuery.limit(data.limit).offset(data.offset);
const csvData = generateCSV(log);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="access-audit-logs-${data.orgId}-${Date.now()}.csv"`);
return res.send(csvData);
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,81 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 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 { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { OpenAPITags } from "@server/openApi";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { queryActionAuditLogsParams, queryActionAuditLogsQuery, queryAction } from "./queryActionAuditLog";
import { generateCSV } from "@server/routers/auditLogs/generateCSV";
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/action/export",
description: "Export the action audit log for an organization as CSV",
tags: [OpenAPITags.Org],
request: {
query: queryActionAuditLogsQuery,
params: queryActionAuditLogsParams
},
responses: {}
});
export async function exportActionAuditLogs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = queryActionAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const data = { ...parsedQuery.data, ...parsedParams.data };
const baseQuery = queryAction(data);
const log = await baseQuery.limit(data.limit).offset(data.offset);
const csvData = generateCSV(log);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="action-audit-logs-${data.orgId}-${Date.now()}.csv"`);
return res.send(csvData);
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,17 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 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 "./queryActionAuditLog";
export * from "./exportActionAuditLog";
export * from "./queryAccessAuditLog";
export * from "./exportAccessAuditLog";

View File

@@ -0,0 +1,258 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 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 { accessAuditLog, db, resources } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { eq, gt, lt, and, count } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
import response from "@server/lib/response";
import logger from "@server/logger";
export const queryAccessAuditLogsQuery = z.object({
// iso string just validate its a parseable date
timeStart: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
message: "timeStart must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000)),
timeEnd: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
message: "timeEnd must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.default(new Date().toISOString()),
action: z
.union([z.boolean(), z.string()])
.transform((val) => (typeof val === "string" ? val === "true" : val))
.optional(),
actorType: z.string().optional(),
actorId: z.string().optional(),
resourceId: z
.string()
.optional()
.transform(Number)
.pipe(z.number().int().positive())
.optional(),
actor: z.string().optional(),
type: z.string().optional(),
location: z.string().optional(),
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
export const queryAccessAuditLogsParams = z.object({
orgId: z.string()
});
export const queryAccessAuditLogsCombined = queryAccessAuditLogsQuery.merge(
queryAccessAuditLogsParams
);
type Q = z.infer<typeof queryAccessAuditLogsCombined>;
function getWhere(data: Q) {
return and(
gt(accessAuditLog.timestamp, data.timeStart),
lt(accessAuditLog.timestamp, data.timeEnd),
eq(accessAuditLog.orgId, data.orgId),
data.resourceId
? eq(accessAuditLog.resourceId, data.resourceId)
: undefined,
data.actor ? eq(accessAuditLog.actor, data.actor) : undefined,
data.actorType
? eq(accessAuditLog.actorType, data.actorType)
: undefined,
data.actorId ? eq(accessAuditLog.actorId, data.actorId) : undefined,
data.location ? eq(accessAuditLog.location, data.location) : undefined,
data.type ? eq(accessAuditLog.type, data.type) : undefined,
data.action !== undefined
? eq(accessAuditLog.action, data.action)
: undefined
);
}
export function queryAccess(data: Q) {
return db
.select({
orgId: accessAuditLog.orgId,
action: accessAuditLog.action,
actorType: accessAuditLog.actorType,
actorId: accessAuditLog.actorId,
resourceId: accessAuditLog.resourceId,
resourceName: resources.name,
resourceNiceId: resources.niceId,
ip: accessAuditLog.ip,
location: accessAuditLog.location,
userAgent: accessAuditLog.userAgent,
metadata: accessAuditLog.metadata,
type: accessAuditLog.type,
timestamp: accessAuditLog.timestamp,
actor: accessAuditLog.actor
})
.from(accessAuditLog)
.leftJoin(
resources,
eq(accessAuditLog.resourceId, resources.resourceId)
)
.where(getWhere(data))
.orderBy(accessAuditLog.timestamp);
}
export function countAccessQuery(data: Q) {
const countQuery = db
.select({ count: count() })
.from(accessAuditLog)
.where(getWhere(data));
return countQuery;
}
async function queryUniqueFilterAttributes(
timeStart: number,
timeEnd: number,
orgId: string
) {
const baseConditions = and(
gt(accessAuditLog.timestamp, timeStart),
lt(accessAuditLog.timestamp, timeEnd),
eq(accessAuditLog.orgId, orgId)
);
// Get unique actors
const uniqueActors = await db
.selectDistinct({
actor: accessAuditLog.actor
})
.from(accessAuditLog)
.where(baseConditions);
// Get unique locations
const uniqueLocations = await db
.selectDistinct({
locations: accessAuditLog.location
})
.from(accessAuditLog)
.where(baseConditions);
// Get unique resources with names
const uniqueResources = await db
.selectDistinct({
id: accessAuditLog.resourceId,
name: resources.name
})
.from(accessAuditLog)
.leftJoin(
resources,
eq(accessAuditLog.resourceId, resources.resourceId)
)
.where(baseConditions);
return {
actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null),
resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null),
locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null)
};
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/access",
description: "Query the access audit log for an organization",
tags: [OpenAPITags.Org],
request: {
query: queryAccessAuditLogsQuery,
params: queryAccessAuditLogsParams
},
responses: {}
});
export async function queryAccessAuditLogs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = queryAccessAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const data = { ...parsedQuery.data, ...parsedParams.data };
const baseQuery = queryAccess(data);
const log = await baseQuery.limit(data.limit).offset(data.offset);
const totalCountResult = await countAccessQuery(data);
const totalCount = totalCountResult[0].count;
const filterAttributes = await queryUniqueFilterAttributes(
data.timeStart,
data.timeEnd,
data.orgId
);
return response<QueryAccessAuditLogResponse>(res, {
data: {
log: log,
pagination: {
total: totalCount,
limit: data.limit,
offset: data.offset
},
filterAttributes
},
success: true,
error: false,
message: "Access audit logs retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,211 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 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 { actionAuditLog, db } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { eq, gt, lt, and, count } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
import response from "@server/lib/response";
import logger from "@server/logger";
export const queryActionAuditLogsQuery = z.object({
// iso string just validate its a parseable date
timeStart: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
message: "timeStart must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000)),
timeEnd: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
message: "timeEnd must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.default(new Date().toISOString()),
action: z.string().optional(),
actorType: z.string().optional(),
actorId: z.string().optional(),
actor: z.string().optional(),
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
export const queryActionAuditLogsParams = z.object({
orgId: z.string()
});
export const queryActionAuditLogsCombined =
queryActionAuditLogsQuery.merge(queryActionAuditLogsParams);
type Q = z.infer<typeof queryActionAuditLogsCombined>;
function getWhere(data: Q) {
return and(
gt(actionAuditLog.timestamp, data.timeStart),
lt(actionAuditLog.timestamp, data.timeEnd),
eq(actionAuditLog.orgId, data.orgId),
data.actor ? eq(actionAuditLog.actor, data.actor) : undefined,
data.actorType ? eq(actionAuditLog.actorType, data.actorType) : undefined,
data.actorId ? eq(actionAuditLog.actorId, data.actorId) : undefined,
data.action ? eq(actionAuditLog.action, data.action) : undefined
);
}
export function queryAction(data: Q) {
return db
.select({
orgId: actionAuditLog.orgId,
action: actionAuditLog.action,
actorType: actionAuditLog.actorType,
metadata: actionAuditLog.metadata,
actorId: actionAuditLog.actorId,
timestamp: actionAuditLog.timestamp,
actor: actionAuditLog.actor
})
.from(actionAuditLog)
.where(getWhere(data))
.orderBy(actionAuditLog.timestamp);
}
export function countActionQuery(data: Q) {
const countQuery = db
.select({ count: count() })
.from(actionAuditLog)
.where(getWhere(data));
return countQuery;
}
async function queryUniqueFilterAttributes(
timeStart: number,
timeEnd: number,
orgId: string
) {
const baseConditions = and(
gt(actionAuditLog.timestamp, timeStart),
lt(actionAuditLog.timestamp, timeEnd),
eq(actionAuditLog.orgId, orgId)
);
// Get unique actors
const uniqueActors = await db
.selectDistinct({
actor: actionAuditLog.actor
})
.from(actionAuditLog)
.where(baseConditions);
const uniqueActions = await db
.selectDistinct({
action: actionAuditLog.action
})
.from(actionAuditLog)
.where(baseConditions);
return {
actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null),
actions: uniqueActions.map(row => row.action).filter((action): action is string => action !== null),
};
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/action",
description: "Query the action audit log for an organization",
tags: [OpenAPITags.Org],
request: {
query: queryActionAuditLogsQuery,
params: queryActionAuditLogsParams
},
responses: {}
});
export async function queryActionAuditLogs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = queryActionAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const data = { ...parsedQuery.data, ...parsedParams.data };
const baseQuery = queryAction(data);
const log = await baseQuery.limit(data.limit).offset(data.offset);
const totalCountResult = await countActionQuery(data);
const totalCount = totalCountResult[0].count;
const filterAttributes = await queryUniqueFilterAttributes(
data.timeStart,
data.timeEnd,
data.orgId
);
return response<QueryActionAuditLogResponse>(res, {
data: {
log: log,
pagination: {
total: totalCount,
limit: data.limit,
offset: data.offset
},
filterAttributes
},
success: true,
error: false,
message: "Action audit logs retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -21,20 +21,21 @@ 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 logs from "#private/routers/auditLogs";
import { Router } from "express";
import {
verifyOrgAccess,
verifyUserHasAction,
verifyUserIsOrgOwner,
verifyUserIsServerAdmin
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import {
logActionAudit,
verifyCertificateAccess,
verifyIdpAccess,
verifyLoginPageAccess,
verifyRemoteExitNodeAccess
verifyRemoteExitNodeAccess,
verifyValidSubscription
} from "#private/middlewares";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import createHttpError from "http-errors";
@@ -72,7 +73,8 @@ authenticated.put(
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createIdp),
orgIdp.createOrgOidcIdp
logActionAudit(ActionsEnum.createIdp),
orgIdp.createOrgOidcIdp,
);
authenticated.post(
@@ -81,7 +83,8 @@ authenticated.post(
verifyOrgAccess,
verifyIdpAccess,
verifyUserHasAction(ActionsEnum.updateIdp),
orgIdp.updateOrgOidcIdp
logActionAudit(ActionsEnum.updateIdp),
orgIdp.updateOrgOidcIdp,
);
authenticated.delete(
@@ -90,7 +93,8 @@ authenticated.delete(
verifyOrgAccess,
verifyIdpAccess,
verifyUserHasAction(ActionsEnum.deleteIdp),
orgIdp.deleteOrgIdp
logActionAudit(ActionsEnum.deleteIdp),
orgIdp.deleteOrgIdp,
);
authenticated.get(
@@ -127,7 +131,8 @@ authenticated.post(
verifyOrgAccess,
verifyCertificateAccess,
verifyUserHasAction(ActionsEnum.restartCertificate),
certificates.restartCertificate
logActionAudit(ActionsEnum.restartCertificate),
certificates.restartCertificate,
);
if (build === "saas") {
@@ -152,14 +157,16 @@ if (build === "saas") {
"/org/:orgId/billing/create-checkout-session",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.createCheckoutSession
logActionAudit(ActionsEnum.billing),
billing.createCheckoutSession,
);
authenticated.post(
"/org/:orgId/billing/create-portal-session",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.createPortalSession
logActionAudit(ActionsEnum.billing),
billing.createPortalSession,
);
authenticated.get(
@@ -206,7 +213,8 @@ authenticated.put(
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createRemoteExitNode),
remoteExitNode.createRemoteExitNode
logActionAudit(ActionsEnum.createRemoteExitNode),
remoteExitNode.createRemoteExitNode,
);
authenticated.get(
@@ -240,7 +248,8 @@ authenticated.delete(
verifyOrgAccess,
verifyRemoteExitNodeAccess,
verifyUserHasAction(ActionsEnum.deleteRemoteExitNode),
remoteExitNode.deleteRemoteExitNode
logActionAudit(ActionsEnum.deleteRemoteExitNode),
remoteExitNode.deleteRemoteExitNode,
);
authenticated.put(
@@ -248,7 +257,8 @@ authenticated.put(
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createLoginPage),
loginPage.createLoginPage
logActionAudit(ActionsEnum.createLoginPage),
loginPage.createLoginPage,
);
authenticated.post(
@@ -257,7 +267,8 @@ authenticated.post(
verifyOrgAccess,
verifyLoginPageAccess,
verifyUserHasAction(ActionsEnum.updateLoginPage),
loginPage.updateLoginPage
logActionAudit(ActionsEnum.updateLoginPage),
loginPage.updateLoginPage,
);
authenticated.delete(
@@ -266,7 +277,8 @@ authenticated.delete(
verifyOrgAccess,
verifyLoginPageAccess,
verifyUserHasAction(ActionsEnum.deleteLoginPage),
loginPage.deleteLoginPage
logActionAudit(ActionsEnum.deleteLoginPage),
loginPage.deleteLoginPage,
);
authenticated.get(
@@ -334,3 +346,41 @@ authenticated.post(
verifyUserIsServerAdmin,
license.recheckStatus
);
authenticated.get(
"/org/:orgId/logs/action",
verifyValidLicense,
verifyValidSubscription,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.exportLogs),
logs.queryActionAuditLogs
);
authenticated.get(
"/org/:orgId/logs/action/export",
verifyValidLicense,
verifyValidSubscription,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.exportLogs),
logActionAudit(ActionsEnum.exportLogs),
logs.exportActionAuditLogs
);
authenticated.get(
"/org/:orgId/logs/access",
verifyValidLicense,
verifyValidSubscription,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.exportLogs),
logs.queryAccessAuditLogs
);
authenticated.get(
"/org/:orgId/logs/access/export",
verifyValidLicense,
verifyValidSubscription,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.exportLogs),
logActionAudit(ActionsEnum.exportLogs),
logs.exportAccessAuditLogs
);

View File

@@ -35,7 +35,9 @@ import {
loginPageOrg,
LoginPage,
resourceHeaderAuth,
ResourceHeaderAuth
ResourceHeaderAuth,
orgs,
requestAuditLog
} from "@server/db";
import {
resources,
@@ -270,7 +272,8 @@ hybridRouter.get(
remoteExitNode.exitNodeId,
["newt", "local", "wireguard"], // Allow them to use all the site types
true, // But don't allow domain namespace resources
false // Dont include login pages
false, // Dont include login pages,
true // allow raw resources
);
return response(res, {
@@ -1583,3 +1586,193 @@ hybridRouter.post(
}
}
);
hybridRouter.get(
"/org/:orgId/get-retention-days",
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsedParams = getOrgLoginPageParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const remoteExitNode = req.remoteExitNode;
if (!remoteExitNode || !remoteExitNode.exitNodeId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Remote exit node not found"
)
);
}
if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) {
// If the exit node is not allowed for the org, return an error
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Exit node not allowed for this organization"
)
);
}
const [org] = await db
.select({
settingsLogRetentionDaysRequest:
orgs.settingsLogRetentionDaysRequest
})
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
return response(res, {
data: {
settingsLogRetentionDaysRequest:
org.settingsLogRetentionDaysRequest
},
success: true,
error: false,
message: "Log retention days retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred..."
)
);
}
}
);
const batchLogsSchema = z.object({
logs: z.array(
z.object({
timestamp: z.number(),
orgId: z.string().optional(),
actorType: z.string().optional(),
actor: z.string().optional(),
actorId: z.string().optional(),
metadata: z.string().nullable(),
action: z.boolean(),
resourceId: z.number().optional(),
reason: z.number(),
location: z.string().optional(),
originalRequestURL: z.string(),
scheme: z.string(),
host: z.string(),
path: z.string(),
method: z.string(),
ip: z.string().optional(),
tls: z.boolean()
})
)
});
hybridRouter.post(
"/org/:orgId/logs/batch",
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsedParams = getOrgLoginPageParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const parsedBody = batchLogsSchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { logs } = parsedBody.data;
const remoteExitNode = req.remoteExitNode;
if (!remoteExitNode || !remoteExitNode.exitNodeId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Remote exit node not found"
)
);
}
if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) {
// If the exit node is not allowed for the org, return an error
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Exit node not allowed for this organization"
)
);
}
// Batch insert all logs in a single query
const logEntries = logs.map((logEntry) => ({
timestamp: logEntry.timestamp,
orgId: logEntry.orgId,
actorType: logEntry.actorType,
actor: logEntry.actor,
actorId: logEntry.actorId,
metadata: logEntry.metadata,
action: logEntry.action,
resourceId: logEntry.resourceId,
reason: logEntry.reason,
location: logEntry.location,
// userAgent: data.userAgent, // TODO: add this
// headers: data.body.headers,
// query: data.body.query,
originalRequestURL: logEntry.originalRequestURL,
scheme: logEntry.scheme,
host: logEntry.host,
path: logEntry.path,
method: logEntry.method,
ip: logEntry.ip,
tls: logEntry.tls
}));
await db.insert(requestAuditLog).values(logEntries);
return response(res, {
data: null,
success: true,
error: false,
message: "Logs saved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred..."
)
);
}
}
);

View File

@@ -23,6 +23,7 @@ import {
import { ActionsEnum } from "@server/auth/actions";
import { unauthenticated as ua, authenticated as a } from "@server/routers/integration";
import { logActionAudit } from "#private/middlewares";
export const unauthenticated = ua;
export const authenticated = a;
@@ -31,12 +32,14 @@ authenticated.post(
`/org/:orgId/send-usage-notification`,
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
verifyApiKeyHasAction(ActionsEnum.sendUsageNotification),
org.sendUsageNotification
logActionAudit(ActionsEnum.sendUsageNotification),
org.sendUsageNotification,
);
authenticated.delete(
"/idp/:idpId",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.deleteIdp),
orgIdp.deleteOrgIdp
logActionAudit(ActionsEnum.deleteIdp),
orgIdp.deleteOrgIdp,
);

View File

@@ -0,0 +1,68 @@
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { OpenAPITags } from "@server/openApi";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, queryRequest } from "./queryRequstAuditLog";
import { generateCSV } from "./generateCSV";
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/request",
description: "Query the request audit log for an organization",
tags: [OpenAPITags.Org],
request: {
query: queryAccessAuditLogsQuery,
params: queryRequestAuditLogsParams
},
responses: {}
});
export async function exportRequestAuditLogs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = queryRequestAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const data = { ...parsedQuery.data, ...parsedParams.data };
const baseQuery = queryRequest(data);
const log = await baseQuery.limit(data.limit).offset(data.offset);
const csvData = generateCSV(log);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"`);
return res.send(csvData);
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,16 @@
export function generateCSV(data: any[]): string {
if (data.length === 0) {
return "orgId,action,actorType,timestamp,actor\n";
}
const headers = Object.keys(data[0]).join(",");
const rows = data.map(row =>
Object.values(row).map(value =>
typeof value === 'string' && value.includes(',')
? `"${value.replace(/"/g, '""')}"`
: value
).join(",")
);
return [headers, ...rows].join("\n");
}

View File

@@ -0,0 +1,2 @@
export * from "./queryRequstAuditLog";
export * from "./exportRequstAuditLog";

View File

@@ -0,0 +1,276 @@
import { db, requestAuditLog, resources } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { eq, gt, lt, and, count } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
import response from "@server/lib/response";
import logger from "@server/logger";
export const queryAccessAuditLogsQuery = z.object({
// iso string just validate its a parseable date
timeStart: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
message: "timeStart must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000)),
timeEnd: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
message: "timeEnd must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.default(new Date().toISOString()),
action: z
.union([z.boolean(), z.string()])
.transform((val) => (typeof val === "string" ? val === "true" : val))
.optional(),
method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(),
reason: z
.string()
.optional()
.transform(Number)
.pipe(z.number().int().positive())
.optional(),
resourceId: z
.string()
.optional()
.transform(Number)
.pipe(z.number().int().positive())
.optional(),
actor: z.string().optional(),
location: z.string().optional(),
host: z.string().optional(),
path: z.string().optional(),
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
export const queryRequestAuditLogsParams = z.object({
orgId: z.string()
});
export const queryRequestAuditLogsCombined =
queryAccessAuditLogsQuery.merge(queryRequestAuditLogsParams);
type Q = z.infer<typeof queryRequestAuditLogsCombined>;
function getWhere(data: Q) {
return and(
gt(requestAuditLog.timestamp, data.timeStart),
lt(requestAuditLog.timestamp, data.timeEnd),
eq(requestAuditLog.orgId, data.orgId),
data.resourceId
? eq(requestAuditLog.resourceId, data.resourceId)
: undefined,
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
data.method ? eq(requestAuditLog.method, data.method) : undefined,
data.reason ? eq(requestAuditLog.reason, data.reason) : undefined,
data.host ? eq(requestAuditLog.host, data.host) : undefined,
data.location ? eq(requestAuditLog.location, data.location) : undefined,
data.path ? eq(requestAuditLog.path, data.path) : undefined,
data.action !== undefined
? eq(requestAuditLog.action, data.action)
: undefined
);
}
export function queryRequest(data: Q) {
return db
.select({
timestamp: requestAuditLog.timestamp,
orgId: requestAuditLog.orgId,
action: requestAuditLog.action,
reason: requestAuditLog.reason,
actorType: requestAuditLog.actorType,
actor: requestAuditLog.actor,
actorId: requestAuditLog.actorId,
resourceId: requestAuditLog.resourceId,
ip: requestAuditLog.ip,
location: requestAuditLog.location,
userAgent: requestAuditLog.userAgent,
metadata: requestAuditLog.metadata,
headers: requestAuditLog.headers,
query: requestAuditLog.query,
originalRequestURL: requestAuditLog.originalRequestURL,
scheme: requestAuditLog.scheme,
host: requestAuditLog.host,
path: requestAuditLog.path,
method: requestAuditLog.method,
tls: requestAuditLog.tls,
resourceName: resources.name,
resourceNiceId: resources.niceId
})
.from(requestAuditLog)
.leftJoin(
resources,
eq(requestAuditLog.resourceId, resources.resourceId)
) // TODO: Is this efficient?
.where(getWhere(data))
.orderBy(requestAuditLog.timestamp);
}
export function countRequestQuery(data: Q) {
const countQuery = db
.select({ count: count() })
.from(requestAuditLog)
.where(getWhere(data));
return countQuery;
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/request",
description: "Query the request audit log for an organization",
tags: [OpenAPITags.Org],
request: {
query: queryAccessAuditLogsQuery,
params: queryRequestAuditLogsParams
},
responses: {}
});
async function queryUniqueFilterAttributes(
timeStart: number,
timeEnd: number,
orgId: string
) {
const baseConditions = and(
gt(requestAuditLog.timestamp, timeStart),
lt(requestAuditLog.timestamp, timeEnd),
eq(requestAuditLog.orgId, orgId)
);
// Get unique actors
const uniqueActors = await db
.selectDistinct({
actor: requestAuditLog.actor
})
.from(requestAuditLog)
.where(baseConditions);
// Get unique locations
const uniqueLocations = await db
.selectDistinct({
locations: requestAuditLog.location
})
.from(requestAuditLog)
.where(baseConditions);
// Get unique actors
const uniqueHosts = await db
.selectDistinct({
hosts: requestAuditLog.host
})
.from(requestAuditLog)
.where(baseConditions);
// Get unique actors
const uniquePaths = await db
.selectDistinct({
paths: requestAuditLog.path
})
.from(requestAuditLog)
.where(baseConditions);
// Get unique resources with names
const uniqueResources = await db
.selectDistinct({
id: requestAuditLog.resourceId,
name: resources.name
})
.from(requestAuditLog)
.leftJoin(
resources,
eq(requestAuditLog.resourceId, resources.resourceId)
)
.where(baseConditions);
return {
actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null),
resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null),
locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null),
hosts: uniqueHosts.map(row => row.hosts).filter((host): host is string => host !== null),
paths: uniquePaths.map(row => row.paths).filter((path): path is string => path !== null)
};
}
export async function queryRequestAuditLogs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = queryRequestAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const data = { ...parsedQuery.data, ...parsedParams.data };
const baseQuery = queryRequest(data);
const log = await baseQuery.limit(data.limit).offset(data.offset);
const totalCountResult = await countRequestQuery(data);
const totalCount = totalCountResult[0].count;
const filterAttributes = await queryUniqueFilterAttributes(
data.timeStart,
data.timeEnd,
data.orgId
);
return response<QueryRequestAuditLogResponse>(res, {
data: {
log: log,
pagination: {
total: totalCount,
limit: data.limit,
offset: data.offset
},
filterAttributes
},
success: true,
error: false,
message: "Action audit logs retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,93 @@
export type QueryActionAuditLogResponse = {
log: {
orgId: string;
action: string;
actorType: string;
actorId: string;
metadata: string | null;
timestamp: number;
actor: string;
}[];
pagination: {
total: number;
limit: number;
offset: number;
};
filterAttributes: {
actors: string[];
};
};
export type QueryRequestAuditLogResponse = {
log: {
timestamp: number;
action: boolean;
reason: number;
orgId: string | null;
actorType: string | null;
actor: string | null;
actorId: string | null;
resourceId: number | null;
resourceNiceId: string | null;
resourceName: string | null;
ip: string | null;
location: string | null;
userAgent: string | null;
metadata: string | null;
headers: string | null;
query: string | null;
originalRequestURL: string | null;
scheme: string | null;
host: string | null;
path: string | null;
method: string | null;
tls: boolean | null;
}[];
pagination: {
total: number;
limit: number;
offset: number;
};
filterAttributes: {
actors: string[];
resources: {
id: number;
name: string | null;
}[];
locations: string[];
hosts: string[];
paths: string[];
};
};
export type QueryAccessAuditLogResponse = {
log: {
orgId: string;
action: boolean;
actorType: string | null;
actorId: string | null;
resourceId: number | null;
resourceName: string | null;
resourceNiceId: string | null;
ip: string | null;
location: string | null;
userAgent: string | null;
metadata: string | null;
type: string;
timestamp: number;
actor: string | null;
}[];
pagination: {
total: number;
limit: number;
offset: number;
};
filterAttributes: {
actors: string[];
resources: {
id: number;
name: string | null;
}[];
locations: string[];
};
};

View File

@@ -3,7 +3,7 @@ import {
generateSessionToken,
serializeSessionCookie
} from "@server/auth/sessions/app";
import { db } from "@server/db";
import { db, resources } from "@server/db";
import { users, securityKeys } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
@@ -18,12 +18,14 @@ import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import { verifySession } from "@server/auth/sessions/verifySession";
import { UserType } from "@server/types/UserTypes";
import { logAccessAudit } from "@server/private/lib/logAccessAudit";
export const loginBodySchema = z
.object({
email: z.string().toLowerCase().email(),
password: z.string(),
code: z.string().optional()
code: z.string().optional(),
resourceGuid: z.string().optional()
})
.strict();
@@ -52,7 +54,7 @@ export async function login(
);
}
const { email, password, code } = parsedBody.data;
const { email, password, code, resourceGuid } = parsedBody.data;
try {
const { session: existingSession } = await verifySession(req);
@@ -66,6 +68,28 @@ export async function login(
});
}
let resourceId: number | null = null;
let orgId: string | null = null;
if (resourceGuid) {
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceGuid, resourceGuid))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with GUID ${resourceGuid} not found`
)
);
}
resourceId = resource.resourceId;
orgId = resource.orgId;
}
const existingUserRes = await db
.select()
.from(users)
@@ -78,6 +102,18 @@ export async function login(
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
if (resourceId && orgId) {
logAccessAudit({
orgId: orgId,
resourceId: resourceId,
action: false,
type: "login",
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
}
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
@@ -98,6 +134,18 @@ export async function login(
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
if (resourceId && orgId) {
logAccessAudit({
orgId: orgId,
resourceId: resourceId,
action: false,
type: "login",
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
}
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
@@ -158,6 +206,18 @@ export async function login(
`Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
if (resourceId && orgId) {
logAccessAudit({
orgId: orgId,
resourceId: resourceId,
action: false,
type: "login",
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
}
return next(
createHttpError(
HttpCode.UNAUTHORIZED,

View File

@@ -0,0 +1,191 @@
import { db, orgs, requestAuditLog } from "@server/db";
import logger from "@server/logger";
import { and, eq, lt } from "drizzle-orm";
import cache from "@server/lib/cache";
/**
Reasons:
100 - Allowed by Rule
101 - Allowed No Auth
102 - Valid Access Token
103 - Valid header auth
104 - Valid Pincode
105 - Valid Password
106 - Valid email
107 - Valid SSO
201 - Resource Not Found
202 - Resource Blocked
203 - Dropped by Rule
204 - No Sessions
205 - Temporary Request Token
299 - No More Auth Methods
*/
async function getRetentionDays(orgId: string): Promise<number> {
// check cache first
const cached = cache.get<number>(`org_${orgId}_retentionDays`);
if (cached !== undefined) {
return cached;
}
const [org] = await db
.select({
settingsLogRetentionDaysRequest:
orgs.settingsLogRetentionDaysRequest
})
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return 0;
}
// store the result in cache
cache.set(
`org_${orgId}_retentionDays`,
org.settingsLogRetentionDaysRequest,
300
);
return org.settingsLogRetentionDaysRequest;
}
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
const now = Math.floor(Date.now() / 1000);
const cutoffTimestamp = now - retentionDays * 24 * 60 * 60;
try {
const deleteResult = await db
.delete(requestAuditLog)
.where(
and(
lt(requestAuditLog.timestamp, cutoffTimestamp),
eq(requestAuditLog.orgId, orgId)
)
);
logger.info(
`Cleaned up ${deleteResult.changes} request audit logs older than ${retentionDays} days`
);
} catch (error) {
logger.error("Error cleaning up old request audit logs:", error);
}
}
export async function logRequestAudit(
data: {
action: boolean;
reason: number;
resourceId?: number;
orgId?: string;
location?: string;
user?: { username: string; userId: string };
apiKey?: { name: string | null; apiKeyId: string };
metadata?: any;
// userAgent?: string;
},
body: {
path: string;
originalRequestURL: string;
scheme: string;
host: string;
method: string;
tls: boolean;
sessions?: Record<string, string>;
headers?: Record<string, string>;
query?: Record<string, string>;
requestIp?: string;
}
) {
try {
if (data.orgId) {
const retentionDays = await getRetentionDays(data.orgId);
if (retentionDays === 0) {
// do not log
return;
}
}
let actorType: string | undefined;
let actor: string | undefined;
let actorId: string | undefined;
const user = data.user;
if (user) {
actorType = "user";
actor = user.username;
actorId = user.userId;
}
const apiKey = data.apiKey;
if (apiKey) {
actorType = "apiKey";
actor = apiKey.name || apiKey.apiKeyId;
actorId = apiKey.apiKeyId;
}
// if (!actorType || !actor || !actorId) {
// logger.warn("logRequestAudit: Incomplete actor information");
// return;
// }
const timestamp = Math.floor(Date.now() / 1000);
let metadata = null;
if (metadata) {
metadata = JSON.stringify(metadata);
}
const clientIp = body.requestIp
? (() => {
if (
body.requestIp.startsWith("[") &&
body.requestIp.includes("]")
) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = body.requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
// ivp4
// split at last colon
const lastColonIndex = body.requestIp.lastIndexOf(":");
if (lastColonIndex !== -1) {
return body.requestIp.substring(0, lastColonIndex);
}
return body.requestIp;
})()
: undefined;
await db.insert(requestAuditLog).values({
timestamp,
orgId: data.orgId,
actorType,
actor,
actorId,
metadata,
action: data.action,
resourceId: data.resourceId,
reason: data.reason,
location: data.location,
// userAgent: data.userAgent, // TODO: add this
// headers: data.body.headers,
// query: data.body.query,
originalRequestURL: body.originalRequestURL,
scheme: body.scheme,
host: body.host,
path: body.path,
method: body.method,
ip: clientIp,
tls: body.tls
});
} catch (error) {
logger.error(error);
}
}

View File

@@ -1,9 +1,4 @@
import { generateSessionToken } from "@server/auth/sessions/app";
import {
createResourceSession,
serializeResourceSessionCookie,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import {
getResourceByDomain,
@@ -18,7 +13,6 @@ import {
LoginPage,
Org,
Resource,
ResourceAccessToken,
ResourceHeaderAuth,
ResourcePassword,
ResourcePincode,
@@ -32,7 +26,6 @@ import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import NodeCache from "node-cache";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { getCountryCodeForIp } from "@server/lib/geoip";
@@ -43,11 +36,8 @@ import {
checkOrgAccessPolicy,
enforceResourceSessionLength
} from "#dynamic/lib/checkOrgAccessPolicy";
// We'll see if this speeds anything up
const cache = new NodeCache({
stdTTL: 5 // seconds
});
import { logRequestAudit } from "./logRequestAudit";
import cache from "@server/lib/cache";
const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string()).optional(),
@@ -133,6 +123,10 @@ export async function verifyResourceSession(
logger.debug("Client IP:", { clientIp });
const ipCC = clientIp
? await getCountryCodeFromIp(clientIp)
: undefined;
let cleanHost = host;
// if the host ends with :port, strip it
if (cleanHost.match(/:[0-9]{1,5}$/)) {
@@ -156,17 +150,43 @@ export async function verifyResourceSession(
if (!result) {
logger.debug(`Resource not found ${cleanHost}`);
// TODO: we cant log this for now because we dont know the org
// eventually it would be cool to show this for the server admin
// logRequestAudit(
// {
// action: false,
// reason: 201, //resource not found
// location: ipCC
// },
// parsedBody.data
// );
return notAllowed(res);
}
resourceData = result;
cache.set(resourceCacheKey, resourceData);
cache.set(resourceCacheKey, resourceData, 5);
}
const { resource, pincode, password, headerAuth } = resourceData;
if (!resource) {
logger.debug(`Resource not found ${cleanHost}`);
// TODO: we cant log this for now because we dont know the org
// eventually it would be cool to show this for the server admin
// logRequestAudit(
// {
// action: false,
// reason: 201, //resource not found
// location: ipCC
// },
// parsedBody.data
// );
return notAllowed(res);
}
@@ -174,6 +194,18 @@ export async function verifyResourceSession(
if (blockAccess) {
logger.debug("Resource blocked", host);
logRequestAudit(
{
action: false,
reason: 202, //resource blocked
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return notAllowed(res);
}
@@ -182,14 +214,40 @@ export async function verifyResourceSession(
const action = await checkRules(
resource.resourceId,
clientIp,
path
path,
ipCC
);
if (action == "ACCEPT") {
logger.debug("Resource allowed by rule");
logRequestAudit(
{
action: true,
reason: 100, // allowed by rule
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return allowed(res);
} else if (action == "DROP") {
logger.debug("Resource denied by rule");
// TODO: add rules type
logRequestAudit(
{
action: false,
reason: 203, // dropped by rules
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return notAllowed(res);
} else if (action == "PASS") {
logger.debug(
@@ -210,6 +268,18 @@ export async function verifyResourceSession(
!headerAuth
) {
logger.debug("Resource allowed because no auth");
logRequestAudit(
{
action: true,
reason: 101, // allowed no auth
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return allowed(res);
}
@@ -261,6 +331,21 @@ export async function verifyResourceSession(
}
if (valid && tokenItem) {
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC,
apiKey: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
}
},
parsedBody.data
);
return allowed(res);
}
}
@@ -297,6 +382,21 @@ export async function verifyResourceSession(
}
if (valid && tokenItem) {
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC,
apiKey: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
}
},
parsedBody.data
);
return allowed(res);
}
}
@@ -308,6 +408,18 @@ export async function verifyResourceSession(
logger.debug(
"Resource allowed because header auth is valid (cached)"
);
logRequestAudit(
{
action: true,
reason: 103, // valid header auth
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return allowed(res);
} else if (
await verifyPassword(
@@ -315,8 +427,20 @@ export async function verifyResourceSession(
headerAuth.headerAuthHash
)
) {
cache.set(clientHeaderAuthKey, clientHeaderAuth);
cache.set(clientHeaderAuthKey, clientHeaderAuth, 5);
logger.debug("Resource allowed because header auth is valid");
logRequestAudit(
{
action: true,
reason: 103, // valid header auth
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return allowed(res);
}
@@ -327,6 +451,17 @@ export async function verifyResourceSession(
!password &&
!resource.emailWhitelistEnabled
) {
logRequestAudit(
{
action: false,
reason: 299, // no more auth methods
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return notAllowed(res);
}
} else if (headerAuth) {
@@ -337,6 +472,17 @@ export async function verifyResourceSession(
!password &&
!resource.emailWhitelistEnabled
) {
logRequestAudit(
{
action: false,
reason: 299, // no more auth methods
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return notAllowed(res);
}
}
@@ -349,6 +495,18 @@ export async function verifyResourceSession(
}. IP: ${clientIp}.`
);
}
logRequestAudit(
{
action: false,
reason: 204, // no sessions
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return notAllowed(res);
}
@@ -368,7 +526,7 @@ export async function verifyResourceSession(
);
resourceSession = result?.resourceSession;
cache.set(sessionCacheKey, resourceSession);
cache.set(sessionCacheKey, resourceSession, 5);
}
if (resourceSession?.isRequestToken) {
@@ -382,6 +540,18 @@ export async function verifyResourceSession(
}. IP: ${clientIp}.`
);
}
logRequestAudit(
{
action: false,
reason: 205, // temporary request token
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return notAllowed(res);
}
@@ -404,6 +574,18 @@ export async function verifyResourceSession(
logger.debug(
"Resource allowed because pincode session is valid"
);
logRequestAudit(
{
action: true,
reason: 104, // valid pincode
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return allowed(res);
}
@@ -411,6 +593,18 @@ export async function verifyResourceSession(
logger.debug(
"Resource allowed because password session is valid"
);
logRequestAudit(
{
action: true,
reason: 105, // valid password
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return allowed(res);
}
@@ -421,6 +615,18 @@ export async function verifyResourceSession(
logger.debug(
"Resource allowed because whitelist session is valid"
);
logRequestAudit(
{
action: true,
reason: 106, // valid email
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return allowed(res);
}
@@ -428,6 +634,22 @@ export async function verifyResourceSession(
logger.debug(
"Resource allowed because access token session is valid"
);
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC,
apiKey: {
name: resourceSession.accessTokenTitle,
apiKeyId: resourceSession.accessTokenId
}
},
parsedBody.data
);
return allowed(res);
}
@@ -446,7 +668,7 @@ export async function verifyResourceSession(
resourceData.org
);
cache.set(userAccessCacheKey, allowedUserData);
cache.set(userAccessCacheKey, allowedUserData, 5);
}
if (
@@ -456,6 +678,22 @@ export async function verifyResourceSession(
logger.debug(
"Resource allowed because user session is valid"
);
logRequestAudit(
{
action: true,
reason: 107, // valid sso
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC,
user: {
username: allowedUserData.username,
userId: resourceSession.userId
}
},
parsedBody.data
);
return allowed(res, allowedUserData);
}
}
@@ -474,6 +712,17 @@ export async function verifyResourceSession(
logger.debug(`Redirecting to login at ${redirectPath}`);
logRequestAudit(
{
action: false,
reason: 299, // no more auth methods
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return notAllowed(res, redirectPath, resource.orgId);
} catch (e) {
console.error(e);
@@ -657,7 +906,8 @@ async function isUserAllowedToAccessResource(
async function checkRules(
resourceId: number,
clientIp: string | undefined,
path: string | undefined
path: string | undefined,
ipCC?: string
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
const ruleCacheKey = `rules:${resourceId}`;
@@ -665,7 +915,7 @@ async function checkRules(
if (!rules) {
rules = await getResourceRules(resourceId);
cache.set(ruleCacheKey, rules);
cache.set(ruleCacheKey, rules, 5);
}
if (rules.length === 0) {
@@ -697,7 +947,7 @@ async function checkRules(
return rule.action as any;
} else if (
clientIp &&
rule.match == "GEOIP" &&
rule.match == "COUNTRY" &&
(await isIpInGeoIP(clientIp, rule.value))
) {
return rule.action as any;
@@ -826,11 +1076,20 @@ export function isPathAllowed(pattern: string, path: string): boolean {
return result;
}
async function isIpInGeoIP(ip: string, countryCode: string): Promise<boolean> {
if (countryCode == "ALL") {
async function isIpInGeoIP(
ipCountryCode: string,
checkCountryCode: string
): Promise<boolean> {
if (checkCountryCode == "ALL") {
return true;
}
logger.debug(`IP ${ipCountryCode} is in country: ${checkCountryCode}`);
return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase();
}
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
const geoIpCacheKey = `geoip:${ip}`;
let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
@@ -841,9 +1100,7 @@ async function isIpInGeoIP(ip: string, countryCode: string): Promise<boolean> {
cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
}
logger.debug(`IP ${ip} is in country: ${cachedCountryCode}`);
return cachedCountryCode?.toUpperCase() === countryCode.toUpperCase();
return cachedCountryCode;
}
function extractBasicAuth(

View File

@@ -1,4 +1,4 @@
import { db } from "@server/db";
import { db, olms } from "@server/db";
import {
clients,
orgs,
@@ -16,6 +16,67 @@ import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import NodeCache from "node-cache";
import semver from "semver";
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
async function getLatestOlmVersion(): Promise<string | null> {
try {
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
if (cachedVersion) {
return cachedVersion;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
const response = await fetch(
"https://api.github.com/repos/fosrl/olm/tags",
{
signal: controller.signal
}
);
clearTimeout(timeoutId);
if (!response.ok) {
logger.warn(
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
);
return null;
}
const tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for Olm repository");
return null;
}
const latestVersion = tags[0].name;
olmVersionCache.set("latestOlmVersion", latestVersion);
return latestVersion;
} catch (error: any) {
if (error.name === "AbortError") {
logger.warn(
"Request to fetch latest Olm version timed out (1.5s)"
);
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.warn(
"Connection timeout while fetching latest Olm version"
);
} else {
logger.warn(
"Error fetching latest Olm version:",
error.message || error
);
}
return null;
}
}
const listClientsParamsSchema = z
.object({
@@ -50,10 +111,12 @@ function queryClients(orgId: string, accessibleClientIds: number[]) {
megabytesOut: clients.megabytesOut,
orgName: orgs.name,
type: clients.type,
online: clients.online
online: clients.online,
olmVersion: olms.version
})
.from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.where(
and(
inArray(clients.clientId, accessibleClientIds),
@@ -77,12 +140,20 @@ async function getSiteAssociations(clientIds: number[]) {
.where(inArray(clientSites.clientId, clientIds));
}
type OlmWithUpdateAvailable = Awaited<ReturnType<typeof queryClients>>[0] & {
olmUpdateAvailable?: boolean;
};
export type ListClientsResponse = {
clients: Array<Awaited<ReturnType<typeof queryClients>>[0] & { sites: Array<{
siteId: number;
siteName: string | null;
siteNiceId: string | null;
}> }>;
clients: Array<Awaited<ReturnType<typeof queryClients>>[0] & {
sites: Array<{
siteId: number;
siteName: string | null;
siteNiceId: string | null;
}>
olmUpdateAvailable?: boolean;
}>;
pagination: { total: number; limit: number; offset: number };
};
@@ -206,6 +277,43 @@ export async function listClients(
sites: sitesByClient[client.clientId] || []
}));
const latestOlVersionPromise = getLatestOlmVersion();
const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map(
(client) => {
const OlmWithUpdate: OlmWithUpdateAvailable = { ...client };
// Initially set to false, will be updated if version check succeeds
OlmWithUpdate.olmUpdateAvailable = false;
return OlmWithUpdate;
}
);
// Try to get the latest version, but don't block if it fails
try {
const latestOlVersion = await latestOlVersionPromise;
if (latestOlVersion) {
olmsWithUpdates.forEach((client) => {
try {
client.olmUpdateAvailable = semver.lt(
client.olmVersion ? client.olmVersion : "",
latestOlVersion
);
} catch (error) {
client.olmUpdateAvailable = false;
}
});
}
} catch (error) {
// Log the error but don't let it block the response
logger.warn(
"Failed to check for OLM updates, continuing without update info:",
error
);
}
return response<ListClientsResponse>(res, {
data: {
clients: clientsWithSites,

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, Domain, domains, OrgDomains, orgDomains } from "@server/db";
import { db, Domain, domains, OrgDomains, orgDomains, dnsRecords } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -24,16 +24,21 @@ const paramsSchema = z
const bodySchema = z
.object({
type: z.enum(["ns", "cname", "wildcard"]),
baseDomain: subdomainSchema
baseDomain: subdomainSchema,
certResolver: z.string().optional().nullable(),
preferWildcardCert: z.boolean().optional().nullable() // optional, only for wildcard
})
.strict();
export type CreateDomainResponse = {
domainId: string;
nsRecords?: string[];
cnameRecords?: { baseDomain: string; value: string }[];
aRecords?: { baseDomain: string; value: string }[];
txtRecords?: { baseDomain: string; value: string }[];
certResolver?: string | null;
preferWildcardCert?: boolean | null;
};
// Helper to check if a domain is a subdomain or equal to another domain
@@ -71,7 +76,7 @@ export async function createOrgDomain(
}
const { orgId } = parsedParams.data;
const { type, baseDomain } = parsedBody.data;
const { type, baseDomain, certResolver, preferWildcardCert } = parsedBody.data;
if (build == "oss") {
if (type !== "wildcard") {
@@ -254,7 +259,9 @@ export async function createOrgDomain(
domainId,
baseDomain,
type,
verified: type === "wildcard" ? true : false
verified: type === "wildcard" ? true : false,
certResolver: certResolver || null,
preferWildcardCert: preferWildcardCert || false
})
.returning();
@@ -269,9 +276,24 @@ export async function createOrgDomain(
})
.returning();
// Prepare DNS records to insert
const recordsToInsert = [];
// TODO: This needs to be cross region and not hardcoded
if (type === "ns") {
nsRecords = config.getRawConfig().dns.nameservers as string[];
// Save NS records to database
for (const nsValue of nsRecords) {
recordsToInsert.push({
id: generateId(15),
domainId,
recordType: "NS",
baseDomain: baseDomain,
value: nsValue,
verified: false
});
}
} else if (type === "cname") {
cnameRecords = [
{
@@ -283,6 +305,18 @@ export async function createOrgDomain(
baseDomain: `_acme-challenge.${baseDomain}`
}
];
// Save CNAME records to database
for (const cnameRecord of cnameRecords) {
recordsToInsert.push({
id: generateId(15),
domainId,
recordType: "CNAME",
baseDomain: cnameRecord.baseDomain,
value: cnameRecord.value,
verified: false
});
}
} else if (type === "wildcard") {
aRecords = [
{
@@ -294,6 +328,23 @@ export async function createOrgDomain(
baseDomain: `${baseDomain}`
}
];
// Save A records to database
for (const aRecord of aRecords) {
recordsToInsert.push({
id: generateId(15),
domainId,
recordType: "A",
baseDomain: aRecord.baseDomain,
value: aRecord.value,
verified: true
});
}
}
// Insert all DNS records in batch
if (recordsToInsert.length > 0) {
await trx.insert(dnsRecords).values(recordsToInsert);
}
numOrgDomains = await trx
@@ -325,7 +376,9 @@ export async function createOrgDomain(
cnameRecords,
txtRecords,
nsRecords,
aRecords
aRecords,
certResolver: returned.certResolver,
preferWildcardCert: returned.preferWildcardCert
},
success: true,
error: false,

View File

@@ -0,0 +1,97 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, dnsRecords } from "@server/db";
import { 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";
import { getServerIp } from "@server/lib/serverIpService"; // your in-memory IP module
const getDNSRecordsSchema = z
.object({
domainId: z.string(),
orgId: z.string()
})
.strict();
async function query(domainId: string) {
const records = await db
.select()
.from(dnsRecords)
.where(eq(dnsRecords.domainId, domainId));
return records;
}
export type GetDNSRecordsResponse = Awaited<ReturnType<typeof query>>;
registry.registerPath({
method: "get",
path: "/org/{orgId}/domain/{domainId}/dns-records",
description: "Get all DNS records for a domain by domainId.",
tags: [OpenAPITags.Domain],
request: {
params: z.object({
domainId: z.string(),
orgId: z.string()
})
},
responses: {}
});
export async function getDNSRecords(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getDNSRecordsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { domainId } = parsedParams.data;
const records = await query(domainId);
if (!records || records.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No DNS records found for this domain"
)
);
}
const serverIp = getServerIp();
// Override value for type A or wildcard records
const updatedRecords = records.map(record => {
if ((record.recordType === "A" || record.baseDomain === "*") && serverIp) {
return { ...record, value: serverIp };
}
return record;
});
return response<GetDNSRecordsResponse>(res, {
data: updatedRecords,
success: true,
error: false,
message: "DNS records retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,86 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, domains } 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 { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { domain } from "zod/v4/core/regexes";
const getDomainSchema = z
.object({
domainId: z
.string()
.optional(),
orgId: z.string().optional()
})
.strict();
async function query(domainId?: string, orgId?: string) {
if (domainId) {
const [res] = await db
.select()
.from(domains)
.where(eq(domains.domainId, domainId))
.limit(1);
return res;
}
}
export type GetDomainResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
registry.registerPath({
method: "get",
path: "/org/{orgId}/domain/{domainId}",
description: "Get a domain by domainId.",
tags: [OpenAPITags.Domain],
request: {
params: z.object({
domainId: z.string(),
orgId: z.string()
})
},
responses: {}
});
export async function getDomain(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getDomainSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, domainId } = parsedParams.data;
const domain = await query(domainId, orgId);
if (!domain) {
return next(createHttpError(HttpCode.NOT_FOUND, "Domain not found"));
}
return response<GetDomainResponse>(res, {
data: domain,
success: true,
error: false,
message: "Domain retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,4 +1,7 @@
export * from "./listDomains";
export * from "./createOrgDomain";
export * from "./deleteOrgDomain";
export * from "./restartOrgDomain";
export * from "./restartOrgDomain";
export * from "./getDomain";
export * from "./getDNSRecords";
export * from "./updateDomain";

View File

@@ -42,7 +42,9 @@ async function queryDomains(orgId: string, limit: number, offset: number) {
type: domains.type,
failed: domains.failed,
tries: domains.tries,
configManaged: domains.configManaged
configManaged: domains.configManaged,
certResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
})
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId))

View File

@@ -0,0 +1,161 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, domains, orgDomains } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z
.object({
orgId: z.string(),
domainId: z.string()
})
.strict();
const bodySchema = z
.object({
certResolver: z.string().optional().nullable(),
preferWildcardCert: z.boolean().optional().nullable()
})
.strict();
export type UpdateDomainResponse = {
domainId: string;
certResolver: string | null;
preferWildcardCert: boolean | null;
};
registry.registerPath({
method: "patch",
path: "/org/{orgId}/domain/{domainId}",
description: "Update a domain by domainId.",
tags: [OpenAPITags.Domain],
request: {
params: z.object({
domainId: z.string(),
orgId: z.string()
})
},
responses: {}
});
export async function updateOrgDomain(
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, domainId } = parsedParams.data;
const { certResolver, preferWildcardCert } = parsedBody.data;
const [orgDomain] = await db
.select()
.from(orgDomains)
.where(
and(
eq(orgDomains.orgId, orgId),
eq(orgDomains.domainId, domainId)
)
);
if (!orgDomain) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Domain not found or does not belong to this organization"
)
);
}
const [existingDomain] = await db
.select()
.from(domains)
.where(eq(domains.domainId, domainId));
if (!existingDomain) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Domain not found")
);
}
if (existingDomain.type !== "wildcard") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Domain settings can only be updated for wildcard domains"
)
);
}
const updateData: Partial<{
certResolver: string | null;
preferWildcardCert: boolean;
}> = {};
if (certResolver !== undefined) {
updateData.certResolver = certResolver;
}
if (preferWildcardCert !== undefined && preferWildcardCert !== null) {
updateData.preferWildcardCert = preferWildcardCert;
}
const [updatedDomain] = await db
.update(domains)
.set(updateData)
.where(eq(domains.domainId, domainId))
.returning();
if (!updatedDomain) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to update domain"
)
);
}
return response<UpdateDomainResponse>(res, {
data: {
domainId: updatedDomain.domainId,
certResolver: updatedDomain.certResolver,
preferWildcardCert: updatedDomain.preferWildcardCert
},
success: true,
error: false,
message: "Domain updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -14,6 +14,7 @@ import * as supporterKey from "./supporterKey";
import * as accessToken from "./accessToken";
import * as idp from "./idp";
import * as apiKeys from "./apiKeys";
import * as logs from "./auditLogs";
import HttpCode from "@server/types/HttpCode";
import {
verifyAccessTokenAccess,
@@ -44,6 +45,8 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import createHttpError from "http-errors";
import { build } from "@server/build";
import { createStore } from "#dynamic/lib/rateLimitStore";
import { logActionAudit } from "#dynamic/middlewares";
import { log } from "console";
// Root routes
export const unauthenticated = Router();
@@ -75,7 +78,8 @@ authenticated.post(
"/org/:orgId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateOrg),
org.updateOrg
logActionAudit(ActionsEnum.updateOrg),
org.updateOrg,
);
if (build !== "saas") {
@@ -84,7 +88,8 @@ if (build !== "saas") {
verifyOrgAccess,
verifyUserIsOrgOwner,
verifyUserHasAction(ActionsEnum.deleteOrg),
org.deleteOrg
logActionAudit(ActionsEnum.deleteOrg),
org.deleteOrg,
);
}
@@ -92,6 +97,7 @@ authenticated.put(
"/org/:orgId/site",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createSite),
logActionAudit(ActionsEnum.createSite),
site.createSite
);
authenticated.get(
@@ -149,7 +155,8 @@ authenticated.put(
verifyClientsEnabled,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createClient),
client.createClient
logActionAudit(ActionsEnum.createClient),
client.createClient,
);
authenticated.delete(
@@ -157,7 +164,8 @@ authenticated.delete(
verifyClientsEnabled,
verifyClientAccess,
verifyUserHasAction(ActionsEnum.deleteClient),
client.deleteClient
logActionAudit(ActionsEnum.deleteClient),
client.deleteClient,
);
authenticated.post(
@@ -165,7 +173,8 @@ authenticated.post(
verifyClientsEnabled,
verifyClientAccess, // this will check if the user has access to the client
verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client
client.updateClient
logActionAudit(ActionsEnum.updateClient),
client.updateClient,
);
// authenticated.get(
@@ -178,15 +187,18 @@ authenticated.post(
"/site/:siteId",
verifySiteAccess,
verifyUserHasAction(ActionsEnum.updateSite),
site.updateSite
logActionAudit(ActionsEnum.updateSite),
site.updateSite,
);
authenticated.delete(
"/site/:siteId",
verifySiteAccess,
verifyUserHasAction(ActionsEnum.deleteSite),
site.deleteSite
logActionAudit(ActionsEnum.deleteSite),
site.deleteSite,
);
// TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite"
authenticated.get(
"/site/:siteId/docker/status",
verifySiteAccess,
@@ -203,13 +215,13 @@ authenticated.post(
"/site/:siteId/docker/check",
verifySiteAccess,
verifyUserHasAction(ActionsEnum.getSite),
site.checkDockerSocket
site.checkDockerSocket,
);
authenticated.post(
"/site/:siteId/docker/trigger",
verifySiteAccess,
verifyUserHasAction(ActionsEnum.getSite),
site.triggerFetchContainers
site.triggerFetchContainers,
);
authenticated.get(
"/site/:siteId/docker/containers",
@@ -224,7 +236,8 @@ authenticated.put(
verifyOrgAccess,
verifySiteAccess,
verifyUserHasAction(ActionsEnum.createSiteResource),
siteResource.createSiteResource
logActionAudit(ActionsEnum.createSiteResource),
siteResource.createSiteResource,
);
authenticated.get(
@@ -257,7 +270,8 @@ authenticated.post(
verifySiteAccess,
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.updateSiteResource),
siteResource.updateSiteResource
logActionAudit(ActionsEnum.updateSiteResource),
siteResource.updateSiteResource,
);
authenticated.delete(
@@ -266,14 +280,16 @@ authenticated.delete(
verifySiteAccess,
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.deleteSiteResource),
siteResource.deleteSiteResource
logActionAudit(ActionsEnum.deleteSiteResource),
siteResource.deleteSiteResource,
);
authenticated.put(
"/org/:orgId/resource",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createResource),
resource.createResource
logActionAudit(ActionsEnum.createResource),
resource.createResource,
);
authenticated.get(
@@ -302,6 +318,27 @@ authenticated.get(
domain.listDomains
);
authenticated.get(
"/org/:orgId/domain/:domainId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getDomain),
domain.getDomain
);
authenticated.patch(
"/org/:orgId/domain/:domainId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateOrgDomain),
domain.updateOrgDomain
);
authenticated.get(
"/org/:orgId/domain/:domainId/dns-records",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getDNSRecords),
domain.getDNSRecords
);
authenticated.get(
"/org/:orgId/invitations",
verifyOrgAccess,
@@ -313,15 +350,18 @@ authenticated.delete(
"/org/:orgId/invitations/:inviteId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.removeInvitation),
user.removeInvitation
logActionAudit(ActionsEnum.removeInvitation),
user.removeInvitation,
);
authenticated.post(
"/org/:orgId/create-invite",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.inviteUser),
user.inviteUser
logActionAudit(ActionsEnum.inviteUser),
user.inviteUser,
); // maybe make this /invite/create instead
unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated
authenticated.get(
@@ -354,20 +394,23 @@ authenticated.post(
"/resource/:resourceId",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.updateResource),
resource.updateResource
logActionAudit(ActionsEnum.updateResource),
resource.updateResource,
);
authenticated.delete(
"/resource/:resourceId",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.deleteResource),
resource.deleteResource
logActionAudit(ActionsEnum.deleteResource),
resource.deleteResource,
);
authenticated.put(
"/resource/:resourceId/target",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.createTarget),
target.createTarget
logActionAudit(ActionsEnum.createTarget),
target.createTarget,
);
authenticated.get(
"/resource/:resourceId/targets",
@@ -380,7 +423,8 @@ authenticated.put(
"/resource/:resourceId/rule",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.createResourceRule),
resource.createResourceRule
logActionAudit(ActionsEnum.createResourceRule),
resource.createResourceRule,
);
authenticated.get(
"/resource/:resourceId/rules",
@@ -392,13 +436,15 @@ authenticated.post(
"/resource/:resourceId/rule/:ruleId",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.updateResourceRule),
resource.updateResourceRule
logActionAudit(ActionsEnum.updateResourceRule),
resource.updateResourceRule,
);
authenticated.delete(
"/resource/:resourceId/rule/:ruleId",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.deleteResourceRule),
resource.deleteResourceRule
logActionAudit(ActionsEnum.deleteResourceRule),
resource.deleteResourceRule,
);
authenticated.get(
@@ -411,20 +457,23 @@ authenticated.post(
"/target/:targetId",
verifyTargetAccess,
verifyUserHasAction(ActionsEnum.updateTarget),
target.updateTarget
logActionAudit(ActionsEnum.updateTarget),
target.updateTarget,
);
authenticated.delete(
"/target/:targetId",
verifyTargetAccess,
verifyUserHasAction(ActionsEnum.deleteTarget),
target.deleteTarget
logActionAudit(ActionsEnum.deleteTarget),
target.deleteTarget,
);
authenticated.put(
"/org/:orgId/role",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createRole),
role.createRole
logActionAudit(ActionsEnum.createRole),
role.createRole,
);
authenticated.get(
"/org/:orgId/roles",
@@ -449,14 +498,16 @@ authenticated.delete(
"/role/:roleId",
verifyRoleAccess,
verifyUserHasAction(ActionsEnum.deleteRole),
role.deleteRole
logActionAudit(ActionsEnum.deleteRole),
role.deleteRole,
);
authenticated.post(
"/role/:roleId/add/:userId",
verifyRoleAccess,
verifyUserAccess,
verifyUserHasAction(ActionsEnum.addUserRole),
user.addUserRole
logActionAudit(ActionsEnum.addUserRole),
user.addUserRole,
);
authenticated.post(
@@ -464,7 +515,8 @@ authenticated.post(
verifyResourceAccess,
verifyRoleAccess,
verifyUserHasAction(ActionsEnum.setResourceRoles),
resource.setResourceRoles
logActionAudit(ActionsEnum.setResourceRoles),
resource.setResourceRoles,
);
authenticated.post(
@@ -472,35 +524,40 @@ authenticated.post(
verifyResourceAccess,
verifySetResourceUsers,
verifyUserHasAction(ActionsEnum.setResourceUsers),
resource.setResourceUsers
logActionAudit(ActionsEnum.setResourceUsers),
resource.setResourceUsers,
);
authenticated.post(
`/resource/:resourceId/password`,
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.setResourcePassword),
resource.setResourcePassword
logActionAudit(ActionsEnum.setResourcePassword),
resource.setResourcePassword,
);
authenticated.post(
`/resource/:resourceId/pincode`,
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.setResourcePincode),
resource.setResourcePincode
logActionAudit(ActionsEnum.setResourcePincode),
resource.setResourcePincode,
);
authenticated.post(
`/resource/:resourceId/header-auth`,
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceHeaderAuth),
resource.setResourceHeaderAuth
logActionAudit(ActionsEnum.setResourceHeaderAuth),
resource.setResourceHeaderAuth,
);
authenticated.post(
`/resource/:resourceId/whitelist`,
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceWhitelist),
resource.setResourceWhitelist
logActionAudit(ActionsEnum.setResourceWhitelist),
resource.setResourceWhitelist,
);
authenticated.get(
@@ -514,14 +571,16 @@ authenticated.post(
`/resource/:resourceId/access-token`,
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.generateAccessToken),
accessToken.generateAccessToken
logActionAudit(ActionsEnum.generateAccessToken),
accessToken.generateAccessToken,
);
authenticated.delete(
`/access-token/:accessTokenId`,
verifyAccessTokenAccess,
verifyUserHasAction(ActionsEnum.deleteAcessToken),
accessToken.deleteAccessToken
logActionAudit(ActionsEnum.deleteAcessToken),
accessToken.deleteAccessToken,
);
authenticated.get(
@@ -594,7 +653,8 @@ authenticated.put(
"/org/:orgId/user",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createOrgUser),
user.createOrgUser
logActionAudit(ActionsEnum.createOrgUser),
user.createOrgUser,
);
authenticated.post(
@@ -602,7 +662,8 @@ authenticated.post(
verifyOrgAccess,
verifyUserAccess,
verifyUserHasAction(ActionsEnum.updateOrgUser),
user.updateOrgUser
logActionAudit(ActionsEnum.updateOrgUser),
user.updateOrgUser,
);
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
@@ -625,7 +686,8 @@ authenticated.delete(
verifyOrgAccess,
verifyUserAccess,
verifyUserHasAction(ActionsEnum.removeUser),
user.removeUserOrg
logActionAudit(ActionsEnum.removeUser),
user.removeUserOrg,
);
// authenticated.put(
@@ -755,7 +817,8 @@ authenticated.post(
verifyOrgAccess,
verifyApiKeyAccess,
verifyUserHasAction(ActionsEnum.setApiKeyActions),
apiKeys.setApiKeyActions
logActionAudit(ActionsEnum.setApiKeyActions),
apiKeys.setApiKeyActions,
);
authenticated.get(
@@ -770,7 +833,8 @@ authenticated.put(
`/org/:orgId/api-key`,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createApiKey),
apiKeys.createOrgApiKey
logActionAudit(ActionsEnum.createApiKey),
apiKeys.createOrgApiKey,
);
authenticated.delete(
@@ -778,7 +842,8 @@ authenticated.delete(
verifyOrgAccess,
verifyApiKeyAccess,
verifyUserHasAction(ActionsEnum.deleteApiKey),
apiKeys.deleteOrgApiKey
logActionAudit(ActionsEnum.deleteApiKey),
apiKeys.deleteOrgApiKey,
);
authenticated.get(
@@ -793,7 +858,8 @@ authenticated.put(
`/org/:orgId/domain`,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createOrgDomain),
domain.createOrgDomain
logActionAudit(ActionsEnum.createOrgDomain),
domain.createOrgDomain,
);
authenticated.post(
@@ -801,7 +867,8 @@ authenticated.post(
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction(ActionsEnum.restartOrgDomain),
domain.restartOrgDomain
logActionAudit(ActionsEnum.restartOrgDomain),
domain.restartOrgDomain,
);
authenticated.delete(
@@ -809,7 +876,23 @@ authenticated.delete(
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction(ActionsEnum.deleteOrgDomain),
domain.deleteAccountDomain
logActionAudit(ActionsEnum.deleteOrgDomain),
domain.deleteAccountDomain,
);
authenticated.get(
"/org/:orgId/logs/request",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryRequestAuditLogs
);
authenticated.get(
"/org/:orgId/logs/request/export",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.exportLogs),
logActionAudit(ActionsEnum.exportLogs),
logs.exportRequestAuditLogs
);
// Auth routes
@@ -1132,4 +1215,4 @@ authRouter.delete(
store: createStore()
}),
auth.deleteSecurityKey
);
);

View File

@@ -29,7 +29,7 @@ import {
import HttpCode from "@server/types/HttpCode";
import { Router } from "express";
import { ActionsEnum } from "@server/auth/actions";
import { build } from "@server/build";
import { logActionAudit } from "#dynamic/middlewares";
export const unauthenticated = Router();
@@ -51,7 +51,8 @@ authenticated.put(
"/org",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.createOrg),
org.createOrg
logActionAudit(ActionsEnum.createOrg),
org.createOrg,
);
authenticated.get(
@@ -72,21 +73,24 @@ authenticated.post(
"/org/:orgId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.updateOrg),
org.updateOrg
logActionAudit(ActionsEnum.updateOrg),
org.updateOrg,
);
authenticated.delete(
"/org/:orgId",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.deleteOrg),
org.deleteOrg
logActionAudit(ActionsEnum.deleteOrg),
org.deleteOrg,
);
authenticated.put(
"/org/:orgId/site",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createSite),
site.createSite
logActionAudit(ActionsEnum.createSite),
site.createSite,
);
authenticated.get(
@@ -121,14 +125,16 @@ authenticated.post(
"/site/:siteId",
verifyApiKeySiteAccess,
verifyApiKeyHasAction(ActionsEnum.updateSite),
site.updateSite
logActionAudit(ActionsEnum.updateSite),
site.updateSite,
);
authenticated.delete(
"/site/:siteId",
verifyApiKeySiteAccess,
verifyApiKeyHasAction(ActionsEnum.deleteSite),
site.deleteSite
logActionAudit(ActionsEnum.deleteSite),
site.deleteSite,
);
authenticated.get(
@@ -142,7 +148,8 @@ authenticated.put(
verifyApiKeyOrgAccess,
verifyApiKeySiteAccess,
verifyApiKeyHasAction(ActionsEnum.createSiteResource),
siteResource.createSiteResource
logActionAudit(ActionsEnum.createSiteResource),
siteResource.createSiteResource,
);
authenticated.get(
@@ -175,7 +182,8 @@ authenticated.post(
verifyApiKeySiteAccess,
verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.updateSiteResource),
siteResource.updateSiteResource
logActionAudit(ActionsEnum.updateSiteResource),
siteResource.updateSiteResource,
);
authenticated.delete(
@@ -184,21 +192,24 @@ authenticated.delete(
verifyApiKeySiteAccess,
verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.deleteSiteResource),
siteResource.deleteSiteResource
logActionAudit(ActionsEnum.deleteSiteResource),
siteResource.deleteSiteResource,
);
authenticated.put(
"/org/:orgId/resource",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createResource),
resource.createResource
logActionAudit(ActionsEnum.createResource),
resource.createResource,
);
authenticated.put(
"/org/:orgId/site/:siteId/resource",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createResource),
resource.createResource
logActionAudit(ActionsEnum.createResource),
resource.createResource,
);
authenticated.get(
@@ -233,7 +244,8 @@ authenticated.post(
"/org/:orgId/create-invite",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.inviteUser),
user.inviteUser
logActionAudit(ActionsEnum.inviteUser),
user.inviteUser,
);
authenticated.get(
@@ -261,21 +273,24 @@ authenticated.post(
"/resource/:resourceId",
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.updateResource),
resource.updateResource
logActionAudit(ActionsEnum.updateResource),
resource.updateResource,
);
authenticated.delete(
"/resource/:resourceId",
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.deleteResource),
resource.deleteResource
logActionAudit(ActionsEnum.deleteResource),
resource.deleteResource,
);
authenticated.put(
"/resource/:resourceId/target",
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.createTarget),
target.createTarget
logActionAudit(ActionsEnum.createTarget),
target.createTarget,
);
authenticated.get(
@@ -289,7 +304,8 @@ authenticated.put(
"/resource/:resourceId/rule",
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.createResourceRule),
resource.createResourceRule
logActionAudit(ActionsEnum.createResourceRule),
resource.createResourceRule,
);
authenticated.get(
@@ -303,14 +319,16 @@ authenticated.post(
"/resource/:resourceId/rule/:ruleId",
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.updateResourceRule),
resource.updateResourceRule
logActionAudit(ActionsEnum.updateResourceRule),
resource.updateResourceRule,
);
authenticated.delete(
"/resource/:resourceId/rule/:ruleId",
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.deleteResourceRule),
resource.deleteResourceRule
logActionAudit(ActionsEnum.deleteResourceRule),
resource.deleteResourceRule,
);
authenticated.get(
@@ -324,21 +342,24 @@ authenticated.post(
"/target/:targetId",
verifyApiKeyTargetAccess,
verifyApiKeyHasAction(ActionsEnum.updateTarget),
target.updateTarget
logActionAudit(ActionsEnum.updateTarget),
target.updateTarget,
);
authenticated.delete(
"/target/:targetId",
verifyApiKeyTargetAccess,
verifyApiKeyHasAction(ActionsEnum.deleteTarget),
target.deleteTarget
logActionAudit(ActionsEnum.deleteTarget),
target.deleteTarget,
);
authenticated.put(
"/org/:orgId/role",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createRole),
role.createRole
logActionAudit(ActionsEnum.createRole),
role.createRole,
);
authenticated.get(
@@ -352,7 +373,8 @@ authenticated.delete(
"/role/:roleId",
verifyApiKeyRoleAccess,
verifyApiKeyHasAction(ActionsEnum.deleteRole),
role.deleteRole
logActionAudit(ActionsEnum.deleteRole),
role.deleteRole,
);
authenticated.get(
@@ -367,7 +389,8 @@ authenticated.post(
verifyApiKeyRoleAccess,
verifyApiKeyUserAccess,
verifyApiKeyHasAction(ActionsEnum.addUserRole),
user.addUserRole
logActionAudit(ActionsEnum.addUserRole),
user.addUserRole,
);
authenticated.post(
@@ -375,7 +398,8 @@ authenticated.post(
verifyApiKeyResourceAccess,
verifyApiKeyRoleAccess,
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
resource.setResourceRoles
logActionAudit(ActionsEnum.setResourceRoles),
resource.setResourceRoles,
);
authenticated.post(
@@ -383,35 +407,40 @@ authenticated.post(
verifyApiKeyResourceAccess,
verifyApiKeySetResourceUsers,
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
resource.setResourceUsers
logActionAudit(ActionsEnum.setResourceUsers),
resource.setResourceUsers,
);
authenticated.post(
`/resource/:resourceId/password`,
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.setResourcePassword),
resource.setResourcePassword
logActionAudit(ActionsEnum.setResourcePassword),
resource.setResourcePassword,
);
authenticated.post(
`/resource/:resourceId/pincode`,
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.setResourcePincode),
resource.setResourcePincode
logActionAudit(ActionsEnum.setResourcePincode),
resource.setResourcePincode,
);
authenticated.post(
`/resource/:resourceId/header-auth`,
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth),
resource.setResourceHeaderAuth
logActionAudit(ActionsEnum.setResourceHeaderAuth),
resource.setResourceHeaderAuth,
);
authenticated.post(
`/resource/:resourceId/whitelist`,
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
resource.setResourceWhitelist
logActionAudit(ActionsEnum.setResourceWhitelist),
resource.setResourceWhitelist,
);
authenticated.post(
@@ -439,14 +468,16 @@ authenticated.post(
`/resource/:resourceId/access-token`,
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.generateAccessToken),
accessToken.generateAccessToken
logActionAudit(ActionsEnum.generateAccessToken),
accessToken.generateAccessToken,
);
authenticated.delete(
`/access-token/:accessTokenId`,
verifyApiKeyAccessTokenAccess,
verifyApiKeyHasAction(ActionsEnum.deleteAcessToken),
accessToken.deleteAccessToken
logActionAudit(ActionsEnum.deleteAcessToken),
accessToken.deleteAccessToken,
);
authenticated.get(
@@ -474,7 +505,8 @@ authenticated.post(
"/user/:userId/2fa",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.updateUser),
user.updateUser2FA
logActionAudit(ActionsEnum.updateUser),
user.updateUser2FA,
);
authenticated.get(
@@ -495,7 +527,8 @@ authenticated.put(
"/org/:orgId/user",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createOrgUser),
user.createOrgUser
logActionAudit(ActionsEnum.createOrgUser),
user.createOrgUser,
);
authenticated.post(
@@ -503,7 +536,8 @@ authenticated.post(
verifyApiKeyOrgAccess,
verifyApiKeyUserAccess,
verifyApiKeyHasAction(ActionsEnum.updateOrgUser),
user.updateOrgUser
logActionAudit(ActionsEnum.updateOrgUser),
user.updateOrgUser,
);
authenticated.delete(
@@ -511,7 +545,8 @@ authenticated.delete(
verifyApiKeyOrgAccess,
verifyApiKeyUserAccess,
verifyApiKeyHasAction(ActionsEnum.removeUser),
user.removeUserOrg
logActionAudit(ActionsEnum.removeUser),
user.removeUserOrg,
);
// authenticated.put(
@@ -531,7 +566,8 @@ authenticated.post(
`/org/:orgId/api-key/:apiKeyId/actions`,
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.setApiKeyActions),
apiKeys.setApiKeyActions
logActionAudit(ActionsEnum.setApiKeyActions),
apiKeys.setApiKeyActions,
);
authenticated.get(
@@ -545,28 +581,32 @@ authenticated.put(
`/org/:orgId/api-key`,
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.createApiKey),
apiKeys.createOrgApiKey
logActionAudit(ActionsEnum.createApiKey),
apiKeys.createOrgApiKey,
);
authenticated.delete(
`/org/:orgId/api-key/:apiKeyId`,
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.deleteApiKey),
apiKeys.deleteApiKey
logActionAudit(ActionsEnum.deleteApiKey),
apiKeys.deleteApiKey,
);
authenticated.put(
"/idp/oidc",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.createIdp),
idp.createOidcIdp
logActionAudit(ActionsEnum.createIdp),
idp.createOidcIdp,
);
authenticated.post(
"/idp/:idpId/oidc",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.updateIdp),
idp.updateOidcIdp
logActionAudit(ActionsEnum.updateIdp),
idp.updateOidcIdp,
);
authenticated.get(
@@ -587,21 +627,24 @@ authenticated.put(
"/idp/:idpId/org/:orgId",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.createIdpOrg),
idp.createIdpOrgPolicy
logActionAudit(ActionsEnum.createIdpOrg),
idp.createIdpOrgPolicy,
);
authenticated.post(
"/idp/:idpId/org/:orgId",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.updateIdpOrg),
idp.updateIdpOrgPolicy
logActionAudit(ActionsEnum.updateIdpOrg),
idp.updateIdpOrgPolicy,
);
authenticated.delete(
"/idp/:idpId/org/:orgId",
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg),
idp.deleteIdpOrgPolicy
logActionAudit(ActionsEnum.deleteIdpOrg),
idp.deleteIdpOrgPolicy,
);
authenticated.get(
@@ -640,7 +683,8 @@ authenticated.put(
verifyClientsEnabled,
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createClient),
client.createClient
logActionAudit(ActionsEnum.createClient),
client.createClient,
);
authenticated.delete(
@@ -648,7 +692,8 @@ authenticated.delete(
verifyClientsEnabled,
verifyApiKeyClientAccess,
verifyApiKeyHasAction(ActionsEnum.deleteClient),
client.deleteClient
logActionAudit(ActionsEnum.deleteClient),
client.deleteClient,
);
authenticated.post(
@@ -656,12 +701,14 @@ authenticated.post(
verifyClientsEnabled,
verifyApiKeyClientAccess,
verifyApiKeyHasAction(ActionsEnum.updateClient),
client.updateClient
logActionAudit(ActionsEnum.updateClient),
client.updateClient,
);
authenticated.put(
"/org/:orgId/blueprint",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.applyBlueprint),
org.applyBlueprint
logActionAudit(ActionsEnum.applyBlueprint),
org.applyBlueprint,
);

View File

@@ -1,10 +1,5 @@
import NodeCache from "node-cache";
import { sendToClient } from "#dynamic/routers/ws";
export const dockerSocketCache = new NodeCache({
stdTTL: 3600 // seconds
});
export function fetchContainers(newtId: string) {
const payload = {
type: `newt/socket/fetch`,

View File

@@ -1,8 +1,8 @@
import { MessageHandler } from "@server/routers/ws";
import logger from "@server/logger";
import { dockerSocketCache } from "./dockerSocket";
import { Newt } from "@server/db";
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
import cache from "@server/lib/cache";
export const handleDockerStatusMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;
@@ -24,8 +24,8 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => {
if (available) {
logger.info(`Newt ${newt.newtId} has Docker socket access`);
dockerSocketCache.set(`${newt.newtId}:socketPath`, socketPath, 0);
dockerSocketCache.set(`${newt.newtId}:isAvailable`, available, 0);
cache.set(`${newt.newtId}:socketPath`, socketPath, 0);
cache.set(`${newt.newtId}:isAvailable`, available, 0);
} else {
logger.warn(`Newt ${newt.newtId} does not have Docker socket access`);
}
@@ -54,7 +54,7 @@ export const handleDockerContainersMessage: MessageHandler = async (
);
if (containers && containers.length > 0) {
dockerSocketCache.set(`${newt.newtId}:dockerContainers`, containers, 0);
cache.set(`${newt.newtId}:dockerContainers`, containers, 0);
} else {
logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
}

View File

@@ -49,13 +49,13 @@ export async function getOrg(
const { orgId } = parsedParams.data;
const org = await db
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (org.length === 0) {
if (!org) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@@ -66,7 +66,7 @@ export async function getOrg(
return response<GetOrgResponse>(res, {
data: {
org: org[0]
org
},
success: true,
error: false,

View File

@@ -26,7 +26,19 @@ const updateOrgBodySchema = z
name: z.string().min(1).max(255).optional(),
requireTwoFactor: z.boolean().optional(),
maxSessionLengthHours: z.number().nullable().optional(),
passwordExpiryDays: z.number().nullable().optional()
passwordExpiryDays: z.number().nullable().optional(),
settingsLogRetentionDaysRequest: z
.number()
.min(build === "saas" ? 0 : -1)
.optional(),
settingsLogRetentionDaysAccess: z
.number()
.min(build === "saas" ? 0 : -1)
.optional(),
settingsLogRetentionDaysAction: z
.number()
.min(build === "saas" ? 0 : -1)
.optional()
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
@@ -86,13 +98,32 @@ export async function updateOrg(
parsedBody.data.passwordExpiryDays = undefined;
}
if (
!isLicensed &&
parsedBody.data.settingsLogRetentionDaysRequest &&
parsedBody.data.settingsLogRetentionDaysRequest > 30
) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"You are not allowed to set log retention days greater than 30 because you are not subscribed to the Standard tier"
)
);
}
const updatedOrg = await db
.update(orgs)
.set({
name: parsedBody.data.name,
requireTwoFactor: parsedBody.data.requireTwoFactor,
maxSessionLengthHours: parsedBody.data.maxSessionLengthHours,
passwordExpiryDays: parsedBody.data.passwordExpiryDays
passwordExpiryDays: parsedBody.data.passwordExpiryDays,
settingsLogRetentionDaysRequest:
parsedBody.data.settingsLogRetentionDaysRequest,
settingsLogRetentionDaysAccess:
parsedBody.data.settingsLogRetentionDaysAccess,
settingsLogRetentionDaysAction:
parsedBody.data.settingsLogRetentionDaysAction
})
.where(eq(orgs.orgId, orgId))
.returning();

View File

@@ -10,11 +10,10 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import { createResourceSession } from "@server/auth/sessions/resource";
import logger from "@server/logger";
import {
verifyResourceAccessToken
} from "@server/auth/verifyResourceAccessToken";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import config from "@server/lib/config";
import stoi from "@server/lib/stoi";
import { logAccessAudit } from "@server/private/lib/logAccessAudit";
const authWithAccessTokenBodySchema = z
.object({
@@ -131,6 +130,16 @@ export async function authWithAccessToken(
`Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
);
}
logAccessAudit({
orgId: resource.orgId,
resourceId: resource.resourceId,
action: false,
type: "accessToken",
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
@@ -150,6 +159,15 @@ export async function authWithAccessToken(
doNotExtend: true
});
logAccessAudit({
orgId: resource.orgId,
resourceId: resource.resourceId,
action: true,
type: "accessToken",
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
return response<AuthWithAccessTokenResponse>(res, {
data: {
session: token,

View File

@@ -13,6 +13,7 @@ import { createResourceSession } from "@server/auth/sessions/resource";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import config from "@server/lib/config";
import { logAccessAudit } from "@server/private/lib/logAccessAudit";
export const authWithPasswordBodySchema = z
.object({
@@ -113,6 +114,16 @@ export async function authWithPassword(
`Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
);
}
logAccessAudit({
orgId: org.orgId,
resourceId: resource.resourceId,
action: false,
type: "password",
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password")
);
@@ -129,6 +140,15 @@ export async function authWithPassword(
doNotExtend: true
});
logAccessAudit({
orgId: org.orgId,
resourceId: resource.resourceId,
action: true,
type: "password",
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
return response<AuthWithPasswordResponse>(res, {
data: {
session: token

View File

@@ -12,6 +12,7 @@ import { createResourceSession } from "@server/auth/sessions/resource";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import config from "@server/lib/config";
import { logAccessAudit } from "@server/private/lib/logAccessAudit";
export const authWithPincodeBodySchema = z
.object({
@@ -112,6 +113,16 @@ export async function authWithPincode(
`Resource pin code incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
);
}
logAccessAudit({
orgId: org.orgId,
resourceId: resource.resourceId,
action: false,
type: "pincode",
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
);
@@ -128,6 +139,15 @@ export async function authWithPincode(
doNotExtend: true
});
logAccessAudit({
orgId: org.orgId,
resourceId: resource.resourceId,
action: true,
type: "pincode",
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
return response<AuthWithPincodeResponse>(res, {
data: {
session: token

View File

@@ -1,11 +1,6 @@
import { generateSessionToken } from "@server/auth/sessions/app";
import { db } from "@server/db";
import {
orgs,
resourceOtp,
resources,
resourceWhitelist
} from "@server/db";
import { orgs, resourceOtp, resources, resourceWhitelist } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq, and } from "drizzle-orm";
@@ -17,13 +12,11 @@ import { createResourceSession } from "@server/auth/sessions/resource";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
import logger from "@server/logger";
import config from "@server/lib/config";
import { logAccessAudit } from "@server/private/lib/logAccessAudit";
const authWithWhitelistBodySchema = z
.object({
email: z
.string()
.toLowerCase()
.email(),
email: z.string().toLowerCase().email(),
otp: z.string().optional()
})
.strict();
@@ -126,6 +119,19 @@ export async function authWithWhitelist(
`Email is not whitelisted. Email: ${email}. IP: ${req.ip}.`
);
}
if (org && resource) {
logAccessAudit({
orgId: org.orgId,
resourceId: resource.resourceId,
action: false,
type: "whitelistedEmail",
metadata: { email },
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
}
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
@@ -219,6 +225,16 @@ export async function authWithWhitelist(
doNotExtend: true
});
logAccessAudit({
orgId: org.orgId,
resourceId: resource.resourceId,
action: true,
metadata: { email },
type: "whitelistedEmail",
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
return response<AuthWithWhitelistResponse>(res, {
data: {
session: token

View File

@@ -18,7 +18,7 @@ import { OpenAPITags, registry } from "@server/openApi";
const createResourceRuleSchema = z
.object({
action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.enum(["CIDR", "IP", "PATH", "GEOIP"]),
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]),
value: z.string().min(1),
priority: z.number().int(),
enabled: z.boolean().optional()

View File

@@ -14,6 +14,7 @@ import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { response } from "@server/lib/response";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { logAccessAudit } from "#private/lib/logAccessAudit";
const getExchangeTokenParams = z
.object({
@@ -46,13 +47,13 @@ export async function getExchangeToken(
const { resourceId } = parsedParams.data;
const resource = await db
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (resource.length === 0) {
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@@ -105,6 +106,21 @@ export async function getExchangeToken(
doNotExtend: true
});
if (req.user) {
logAccessAudit({
orgId: resource.orgId,
resourceId: resourceId,
user: {
username: req.user.username,
userId: req.user.userId
},
action: true,
type: "login",
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
}
logger.debug("Request token created successfully");
return response<GetExchangeTokenResponse>(res, {

View File

@@ -99,8 +99,9 @@ const updateRawResourceBodySchema = z
name: z.string().min(1).max(255).optional(),
proxyPort: z.number().int().min(1).max(65535).optional(),
stickySession: z.boolean().optional(),
enabled: z.boolean().optional()
// enableProxy: z.boolean().optional() // always true now
enabled: z.boolean().optional(),
proxyProtocol: z.boolean().optional(),
proxyProtocolVersion: z.number().int().min(1).optional()
})
.strict()
.refine((data) => Object.keys(data).length > 0, {

View File

@@ -30,7 +30,7 @@ const updateResourceRuleParamsSchema = z
const updateResourceRuleSchema = z
.object({
action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(),
match: z.enum(["CIDR", "IP", "PATH", "GEOIP"]).optional(),
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]).optional(),
value: z.string().min(1).optional(),
priority: z.number().int(),
enabled: z.boolean().optional()

View File

@@ -9,14 +9,12 @@ import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import NodeCache from "node-cache";
import semver from "semver";
const newtVersionCache = new NodeCache({ stdTTL: 3600 }); // 1 hours in seconds
import cache from "@server/lib/cache";
async function getLatestNewtVersion(): Promise<string | null> {
try {
const cachedVersion = newtVersionCache.get<string>("latestNewtVersion");
const cachedVersion = cache.get<string>("latestNewtVersion");
if (cachedVersion) {
return cachedVersion;
}
@@ -48,7 +46,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
const latestVersion = tags[0].name;
newtVersionCache.set("latestNewtVersion", latestVersion);
cache.set("latestNewtVersion", latestVersion);
return latestVersion;
} catch (error: any) {

View File

@@ -12,9 +12,9 @@ import stoi from "@server/lib/stoi";
import { sendToClient } from "#dynamic/routers/ws";
import {
fetchContainers,
dockerSocketCache,
dockerSocket
} from "../newt/dockerSocket";
import cache from "@server/lib/cache";
export interface ContainerNetwork {
networkId: string;
@@ -157,7 +157,7 @@ async function triggerFetch(siteId: number) {
// clear the cache for this Newt ID so that the site has to keep asking for the containers
// this is to ensure that the site always gets the latest data
dockerSocketCache.del(`${newt.newtId}:dockerContainers`);
cache.del(`${newt.newtId}:dockerContainers`);
return { siteId, newtId: newt.newtId };
}
@@ -165,7 +165,7 @@ async function triggerFetch(siteId: number) {
async function queryContainers(siteId: number) {
const { newt } = await getSiteAndNewt(siteId);
const result = dockerSocketCache.get(
const result = cache.get(
`${newt.newtId}:dockerContainers`
) as Container[];
if (!result) {
@@ -182,7 +182,7 @@ async function isDockerAvailable(siteId: number): Promise<boolean> {
const { newt } = await getSiteAndNewt(siteId);
const key = `${newt.newtId}:isAvailable`;
const isAvailable = dockerSocketCache.get(key);
const isAvailable = cache.get(key);
return !!isAvailable;
}
@@ -196,8 +196,8 @@ async function getDockerStatus(
const mappedKeys = keys.map((x) => `${newt.newtId}:${x}`);
const result = {
isAvailable: dockerSocketCache.get(mappedKeys[0]) as boolean,
socketPath: dockerSocketCache.get(mappedKeys[1]) as string | undefined
isAvailable: cache.get(mappedKeys[0]) as boolean,
socketPath: cache.get(mappedKeys[1]) as string | undefined
};
return result;

View File

@@ -21,7 +21,8 @@ export async function traefikConfigProvider(
currentExitNodeId,
config.getRawConfig().traefik.site_types,
build == "oss", // filter out the namespace domains in open source
build != "oss" // generate the login pages on the cloud and hybrid
build != "oss", // generate the login pages on the cloud and and enterprise,
config.getRawConfig().traefik.allow_raw_resources
);
if (traefikConfig?.http?.middlewares) {

View File

@@ -1,4 +1,3 @@
import NodeCache from "node-cache";
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
@@ -20,8 +19,7 @@ import { UserType } from "@server/types/UserTypes";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { build } from "@server/build";
const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 });
import cache from "@server/lib/cache";
const inviteUserParamsSchema = z
.object({
@@ -182,7 +180,7 @@ export async function inviteUser(
}
if (existingInvite.length) {
const attempts = regenerateTracker.get<number>(email) || 0;
const attempts = cache.get<number>(email) || 0;
if (attempts >= 3) {
return next(
createHttpError(
@@ -192,7 +190,7 @@ export async function inviteUser(
);
}
regenerateTracker.set(email, attempts + 1);
cache.set(email, attempts + 1);
const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId
const token = generateRandomString(

View File

@@ -37,7 +37,9 @@ async function copyInDomains() {
const configDomains = Object.entries(rawDomains).map(
([key, value]) => ({
domainId: key,
baseDomain: value.base_domain.toLowerCase()
baseDomain: value.base_domain.toLowerCase(),
certResolver: value.cert_resolver || null,
preferWildcardCert: value.prefer_wildcard_cert || null,
})
);
@@ -59,11 +61,11 @@ async function copyInDomains() {
}
}
for (const { domainId, baseDomain } of configDomains) {
for (const { domainId, baseDomain, certResolver, preferWildcardCert } of configDomains) {
if (existingDomainKeys.has(domainId)) {
await trx
.update(domains)
.set({ baseDomain, verified: true, type: "wildcard" })
.set({ baseDomain, verified: true, type: "wildcard", certResolver, preferWildcardCert })
.where(eq(domains.domainId, domainId))
.execute();
} else {
@@ -74,7 +76,9 @@ async function copyInDomains() {
baseDomain,
configManaged: true,
type: "wildcard",
verified: true
verified: true,
certResolver,
preferWildcardCert
})
.execute();
}

View File

@@ -13,6 +13,7 @@ import m5 from "./scriptsPg/1.10.0";
import m6 from "./scriptsPg/1.10.2";
import m7 from "./scriptsPg/1.11.0";
import m8 from "./scriptsPg/1.11.1";
import m9 from "./scriptsPg/1.11.2";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -26,7 +27,8 @@ const migrations = [
{ version: "1.10.0", run: m5 },
{ version: "1.10.2", run: m6 },
{ version: "1.11.0", run: m7 },
{ version: "1.11.1", run: m8 }
{ version: "1.11.1", run: m8 },
{ version: "1.11.2", run: m9 }
// Add new migrations here as they are created
] as {
version: string;

View File

@@ -31,6 +31,7 @@ import m26 from "./scriptsSqlite/1.10.1";
import m27 from "./scriptsSqlite/1.10.2";
import m28 from "./scriptsSqlite/1.11.0";
import m29 from "./scriptsSqlite/1.11.1";
import m30 from "./scriptsSqlite/1.11.2";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -60,7 +61,8 @@ const migrations = [
{ version: "1.10.1", run: m26 },
{ version: "1.10.2", run: m27 },
{ version: "1.11.0", run: m28 },
{ version: "1.11.1", run: m29 }
{ version: "1.11.1", run: m29 },
{ version: "1.11.2", run: m30 }
// Add new migrations here as they are created
] as const;

View File

@@ -0,0 +1,24 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
const version = "1.11.2";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
await db.execute(sql`BEGIN`);
await db.execute(sql`UPDATE "resourceRules" SET "match" = "COUNTRY" WHERE "match" = "GEOIP"`);
await db.execute(sql`COMMIT`);
console.log(`Updated resource rules match value from GEOIP to COUNTRY`);
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Unable to update resource rules match value");
console.log(e);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -0,0 +1,18 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.11.2";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
db.transaction(() => {
db.prepare(`UPDATE resourceRules SET match = "COUNTRY" WHERE match = "GEOIP"`).run();
})();
console.log(`${version} migration complete`);
}