Merge branch 'dev' into audit-logs

This commit is contained in:
Owen
2025-10-27 10:02:32 -07:00
47 changed files with 2183 additions and 511 deletions

View File

@@ -911,9 +911,9 @@ async function checkRules(
) {
return rule.action as any;
} else if (
ipCC &&
rule.match == "GEOIP" &&
(await isIpInGeoIP(ipCC, rule.value))
clientIp &&
rule.match == "COUNTRY" &&
(await isIpInGeoIP(clientIp, rule.value))
) {
return rule.action as any;
}

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

@@ -318,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,

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

@@ -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

@@ -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) {