Merge branch 'dev' into msg-delivery

This commit is contained in:
Owen
2025-12-23 16:56:50 -05:00
139 changed files with 7803 additions and 2116 deletions

View File

@@ -0,0 +1,22 @@
/*
* 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 { z } from "zod";
export const MaintenanceSchema = z.object({
enabled: z.boolean().optional(),
type: z.enum(["forced", "automatic"]).optional(),
title: z.string().max(255).nullable().optional(),
message: z.string().max(2000).nullable().optional(),
"estimated-time": z.string().max(100).nullable().optional()
});

View File

@@ -23,10 +23,10 @@ import {
} from "@server/lib/checkOrgAccessPolicy";
import { UserType } from "@server/types/UserTypes";
export async function enforceResourceSessionLength(
export function enforceResourceSessionLength(
resourceSession: ResourceSession,
org: Org
): Promise<{ valid: boolean; error?: string }> {
): { valid: boolean; error?: string } {
if (org.maxSessionLengthHours) {
const sessionIssuedAt = resourceSession.issuedAt; // may be null
const maxSessionLengthHours = org.maxSessionLengthHours;

View File

@@ -161,8 +161,11 @@ async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
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
// Only cache successful lookups to avoid filling cache with undefined values
if (cachedCountryCode) {
// Cache for longer since IP geolocation doesn't change frequently
cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
}
}
return cachedCountryCode;

View File

@@ -47,24 +47,33 @@ const redirectHttpsMiddlewareName = "redirect-to-https";
const redirectToRootMiddlewareName = "redirect-to-root";
const badgerMiddlewareName = "badger";
// Define extended target type with site information
type TargetWithSite = Target & {
resourceId: number;
targetId: number;
ip: string | null;
method: string | null;
port: number | null;
internalPort: number | null;
enabled: boolean;
health: string | null;
site: {
siteId: number;
type: string;
subnet: string | null;
exitNodeId: number | null;
online: boolean;
};
};
export async function getTraefikConfig(
exitNodeId: number,
siteTypes: string[],
filterOutNamespaceDomains = false,
generateLoginPageRouters = false,
allowRawResources = true
allowRawResources = true,
allowMaintenancePage = true
): Promise<any> {
// Define extended target type with site information
type TargetWithSite = Target & {
site: {
siteId: number;
type: string;
subnet: string | null;
exitNodeId: number | null;
online: boolean;
};
};
// Get resources with their targets and sites in a single optimized query
// Start from sites on this exit node, then join to targets and resources
const resourcesWithTargetsAndSites = await db
@@ -87,6 +96,13 @@ export async function getTraefikConfig(
headers: resources.headers,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType,
maintenanceTitle: resources.maintenanceTitle,
maintenanceMessage: resources.maintenanceMessage,
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
// Target fields
targetId: targets.targetId,
targetEnabled: targets.enabled,
@@ -140,10 +156,6 @@ export async function getTraefikConfig(
sql`(${build != "saas" ? 1 : 0} = 1)` // Dont allow undefined local sites in cloud
)
),
or(
ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record
),
inArray(sites.type, siteTypes),
allowRawResources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
@@ -220,7 +232,13 @@ export async function getTraefikConfig(
rewritePathType: row.rewritePathType,
priority: priority, // may be null, we fallback later
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert
preferWildcardCert: row.preferWildcardCert,
maintenanceModeEnabled: row.maintenanceModeEnabled,
maintenanceModeType: row.maintenanceModeType,
maintenanceTitle: row.maintenanceTitle,
maintenanceMessage: row.maintenanceMessage,
maintenanceEstimatedTime: row.maintenanceEstimatedTime
});
}
@@ -233,6 +251,7 @@ export async function getTraefikConfig(
port: row.port,
internalPort: row.internalPort,
enabled: row.targetEnabled,
health: row.hcHealth,
site: {
siteId: row.siteId,
type: row.siteType,
@@ -278,7 +297,7 @@ export async function getTraefikConfig(
// get the key and the resource
for (const [key, resource] of resourcesMap.entries()) {
const targets = resource.targets;
const targets = resource.targets as TargetWithSite[];
const routerName = `${key}-${resource.name}-router`;
const serviceName = `${key}-${resource.name}-service`;
@@ -308,20 +327,49 @@ export async function getTraefikConfig(
config_output.http.services = {};
}
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
...additionalMiddlewares
];
let rule = `Host(\`${fullDomain}\`)`;
// priority logic
let priority: number;
if (resource.priority && resource.priority != 100) {
priority = resource.priority;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
priority = 100;
if (resource.path && resource.pathMatchType) {
priority += 10;
if (resource.pathMatchType === "exact") {
priority += 5;
} else if (resource.pathMatchType === "prefix") {
priority += 3;
} else if (resource.pathMatchType === "regex") {
priority += 2;
}
if (resource.path === "/") {
priority = 1; // lowest for catch-all
}
}
}
if (!resource.subdomain) {
wildCard = resource.fullDomain;
if (resource.ssl) {
config_output.http.routers![routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule: rule,
priority: priority
};
}
const configDomain = config.getDomain(resource.domainId);
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
@@ -387,13 +435,105 @@ export async function getTraefikConfig(
}
}
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const availableServers = targets.filter((target) => {
if (!target.enabled) return false;
const routerMiddlewares = [
badgerMiddlewareName,
...additionalMiddlewares
];
if (!target.site.online) return false;
if (target.health == "unhealthy") return false;
return true;
});
const hasHealthyServers = availableServers.length > 0;
let showMaintenancePage = false;
if (resource.maintenanceModeEnabled) {
if (resource.maintenanceModeType === "forced") {
showMaintenancePage = true;
// logger.debug(
// `Resource ${resource.name} (${fullDomain}) is in FORCED maintenance mode`
// );
} else if (resource.maintenanceModeType === "automatic") {
showMaintenancePage = !hasHealthyServers;
if (showMaintenancePage) {
logger.warn(
`Resource ${resource.name} (${fullDomain}) has no healthy servers - showing maintenance page (AUTOMATIC mode)`
);
}
}
}
if (showMaintenancePage) {
const maintenanceServiceName = `${key}-maintenance-service`;
const maintenanceRouterName = `${key}-maintenance-router`;
const rewriteMiddlewareName = `${key}-maintenance-rewrite`;
const entrypointHttp =
config.getRawConfig().traefik.http_entrypoint;
const entrypointHttps =
config.getRawConfig().traefik.https_entrypoint;
const fullDomain = resource.fullDomain;
const domainParts = fullDomain.split(".");
const wildCard = resource.subdomain
? `*.${domainParts.slice(1).join(".")}`
: fullDomain;
const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost =
config.getRawConfig().server.internal_hostname;
config_output.http.services[maintenanceServiceName] = {
loadBalancer: {
servers: [
{
url: `http://${maintenanceHost}:${maintenancePort}`
}
],
passHostHeader: true
}
};
// middleware to rewrite path to /maintenance-screen
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[rewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: "/maintenance-screen"
}
};
config_output.http.routers[maintenanceRouterName] = {
entryPoints: [
resource.ssl ? entrypointHttps : entrypointHttp
],
service: maintenanceServiceName,
middlewares: [rewriteMiddlewareName],
rule: rule,
priority: 2000,
...(resource.ssl ? { tls } : {})
};
// Router to allow Next.js assets to load without rewrite
config_output.http.routers[`${maintenanceRouterName}-assets`] =
{
entryPoints: [
resource.ssl ? entrypointHttps : entrypointHttp
],
service: maintenanceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
priority: 2001,
...(resource.ssl ? { tls } : {})
};
// logger.info(`Maintenance mode active for ${fullDomain}`);
continue;
}
// Handle path rewriting middleware
if (
@@ -485,29 +625,6 @@ export async function getTraefikConfig(
}
}
let rule = `Host(\`${fullDomain}\`)`;
// priority logic
let priority: number;
if (resource.priority && resource.priority != 100) {
priority = resource.priority;
} else {
priority = 100;
if (resource.path && resource.pathMatchType) {
priority += 10;
if (resource.pathMatchType === "exact") {
priority += 5;
} else if (resource.pathMatchType === "prefix") {
priority += 3;
} else if (resource.pathMatchType === "regex") {
priority += 2;
}
if (resource.path === "/") {
priority = 1; // lowest for catch-all
}
}
}
if (resource.path && resource.pathMatchType) {
//priority += 1;
// add path to rule based on match type
@@ -538,18 +655,6 @@ export async function getTraefikConfig(
...(resource.ssl ? { tls } : {})
};
if (resource.ssl) {
config_output.http.routers![routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule: rule,
priority: priority
};
}
config_output.http.services![serviceName] = {
loadBalancer: {
servers: (() => {
@@ -559,17 +664,21 @@ export async function getTraefikConfig(
// RECEIVE BANDWIDTH ENDPOINT.
// TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = (
targets as TargetWithSite[]
).some((target: TargetWithSite) => target.site.online);
const anySitesOnline = targets.some(
(target) => target.site.online
);
return (
(targets as TargetWithSite[])
.filter((target: TargetWithSite) => {
targets
.filter((target) => {
if (!target.enabled) {
return false;
}
if (target.health == "unhealthy") {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
@@ -597,7 +706,7 @@ export async function getTraefikConfig(
}
return true;
})
.map((target: TargetWithSite) => {
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
@@ -683,12 +792,12 @@ export async function getTraefikConfig(
loadBalancer: {
servers: (() => {
// Check if any sites are online
const anySitesOnline = (
targets as TargetWithSite[]
).some((target: TargetWithSite) => target.site.online);
const anySitesOnline = targets.some(
(target) => target.site.online
);
return (targets as TargetWithSite[])
.filter((target: TargetWithSite) => {
return targets
.filter((target) => {
if (!target.enabled) {
return false;
}
@@ -715,7 +824,7 @@ export async function getTraefikConfig(
}
return true;
})
.map((target: TargetWithSite) => {
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"

View File

@@ -36,8 +36,11 @@ import {
LoginPage,
resourceHeaderAuth,
ResourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
ResourceHeaderAuthExtendedCompatibility,
orgs,
requestAuditLog
requestAuditLog,
Org
} from "@server/db";
import {
resources,
@@ -76,6 +79,8 @@ import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
import { maxmindLookup } from "@server/db/maxmind";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import semver from "semver";
import { maxmindAsnLookup } from "@server/db/maxmindAsn";
import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy";
// Zod schemas for request validation
const getResourceByDomainParamsSchema = z.strictObject({
@@ -91,6 +96,12 @@ const getUserOrgRoleParamsSchema = z.strictObject({
orgId: z.string().min(1, "Organization ID is required")
});
const getUserOrgSessionVerifySchema = z.strictObject({
userId: z.string().min(1, "User ID is required"),
orgId: z.string().min(1, "Organization ID is required"),
sessionId: z.string().min(1, "Session ID is required")
});
const getRoleResourceAccessParamsSchema = z.strictObject({
roleId: z
.string()
@@ -174,6 +185,8 @@ export type ResourceWithAuth = {
pincode: ResourcePincode | null;
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
org: Org
};
export type UserSessionWithUser = {
@@ -497,6 +510,14 @@ hybridRouter.get(
resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resources.resourceId
)
)
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
.where(eq(resources.fullDomain, domain))
.limit(1);
@@ -529,7 +550,10 @@ hybridRouter.get(
resource: result.resources,
pincode: result.resourcePincode,
password: result.resourcePassword,
headerAuth: result.resourceHeaderAuth
headerAuth: result.resourceHeaderAuth,
headerAuthExtendedCompatibility:
result.resourceHeaderAuthExtendedCompatibility,
org: result.orgs
};
return response<ResourceWithAuth>(res, {
@@ -809,6 +833,69 @@ hybridRouter.get(
}
);
// Get user organization role
hybridRouter.get(
"/user/:userId/org/:orgId/session/:sessionId/verify",
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsedParams = getUserOrgSessionVerifySchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { userId, orgId, sessionId } = 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)) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"User is not authorized to access this organization"
)
);
}
const accessPolicy = await checkOrgAccessPolicy({
orgId,
userId,
sessionId
});
return response(res, {
data: accessPolicy,
success: true,
error: false,
message: "User org access policy retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to get user org role"
)
);
}
}
);
// Check if role has access to resource
hybridRouter.get(
"/role/:roleId/resource/:resourceId/access",
@@ -1238,6 +1325,70 @@ hybridRouter.get(
}
);
const asnIpLookupParamsSchema = z.object({
ip: z.union([z.ipv4(), z.ipv6()])
});
hybridRouter.get(
"/asnip/:ip",
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsedParams = asnIpLookupParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { ip } = parsedParams.data;
if (!maxmindAsnLookup) {
return next(
createHttpError(
HttpCode.SERVICE_UNAVAILABLE,
"ASNIP service is not available"
)
);
}
const result = maxmindAsnLookup.get(ip);
if (!result || !result.autonomous_system_number) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"ASNIP information not found"
)
);
}
const { autonomous_system_number } = result;
logger.debug(
`ASNIP lookup successful for IP ${ip}: ${autonomous_system_number}`
);
return response(res, {
data: { asn: autonomous_system_number },
success: true,
error: false,
message: "GeoIP lookup successful",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to validate resource session token"
)
);
}
}
);
// GERBIL ROUTERS
const getConfigSchema = z.object({
publicKey: z.string(),

View File

@@ -16,6 +16,7 @@ import * as auth from "#private/routers/auth";
import * as orgIdp from "#private/routers/orgIdp";
import * as billing from "#private/routers/billing";
import * as license from "#private/routers/license";
import * as resource from "#private/routers/resource";
import { verifySessionUserMiddleware } from "@server/middlewares";
@@ -37,3 +38,5 @@ internalRouter.post(
);
internalRouter.get(`/license/status`, license.getLicenseStatus);
internalRouter.get("/maintenance/info", resource.getMaintenanceInfo);

View File

@@ -0,0 +1,113 @@
/*
* 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 { z } from "zod";
import { db } from "@server/db";
import { resources } 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 { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import { GetMaintenanceInfoResponse } from "@server/routers/resource/types";
const getMaintenanceInfoSchema = z
.object({
fullDomain: z.string().min(1, "Domain is required")
})
.strict();
async function query(fullDomain: string) {
const [res] = await db
.select({
resourceId: resources.resourceId,
name: resources.name,
fullDomain: resources.fullDomain,
maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType,
maintenanceTitle: resources.maintenanceTitle,
maintenanceMessage: resources.maintenanceMessage,
maintenanceEstimatedTime: resources.maintenanceEstimatedTime
})
.from(resources)
.where(eq(resources.fullDomain, fullDomain))
.limit(1);
return res;
}
registry.registerPath({
method: "get",
path: "/maintenance/info",
description: "Get maintenance information for a resource by domain.",
tags: [OpenAPITags.Resource],
request: {
query: z.object({
fullDomain: z.string()
})
},
responses: {
200: {
description: "Maintenance information retrieved successfully"
},
404: {
description: "Resource not found"
}
}
});
export async function getMaintenanceInfo(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = getMaintenanceInfoSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { fullDomain } = parsedQuery.data;
const maintenanceInfo = await query(fullDomain);
if (!maintenanceInfo) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
return response<GetMaintenanceInfoResponse>(res, {
data: maintenanceInfo,
success: true,
error: false,
message: "Maintenance information retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while retrieving maintenance information"
)
);
}
}

View File

@@ -0,0 +1,14 @@
/*
* 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 "./getMaintenanceInfo";