Merge branch 'dev' into feat/redirect-tables

This commit is contained in:
miloschwartz
2026-10-15 11:47:55 -04:00
175 changed files with 8650 additions and 1759 deletions
@@ -0,0 +1,19 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { z } from "zod";
export const MtlsSchema = z.object({
enabled: z.boolean().optional(),
"ca-certificates": z.array(z.string().min(1)).optional()
});
+34 -11
View File
@@ -384,7 +384,8 @@ export class AuthoritativeDNSServer {
// Get records from cache or database
const records = await this.getResourceRecordsByFullDomain(
queryName,
queryType
queryType,
baseDomain!
);
if (records.length > 0) {
@@ -475,15 +476,30 @@ export class AuthoritativeDNSServer {
const labels = queryName.replace(/\.$/, "").split(".");
// Fast path: O(1) in-memory Set lookup — no DB or network I/O
if (this.allDomains.size > 0) {
for (let i = 0; i < labels.length; i++) {
const candidate = labels.slice(i).join(".");
if (this.allDomains.has(candidate)) {
this.authoritativeDomainCache.set(cacheKey, candidate);
return candidate;
}
// Infrastructure zone cuts (e.g. this deployment's own nameserver
// zone, cname/site extension zones) that this server is
// authoritative for regardless of the customer `domains` table.
// Static config, so always available - checked alongside allDomains
// below rather than gating a separate fast path on it.
const configuredZones = (config.getRawConfig().dns?.zones ?? []).map(
(z) => z.toLowerCase()
);
// Fast path: O(1) in-memory Set/array lookup — no DB or network I/O
for (let i = 0; i < labels.length; i++) {
const candidate = labels.slice(i).join(".");
if (
this.allDomains.has(candidate) ||
configuredZones.includes(candidate)
) {
this.authoritativeDomainCache.set(cacheKey, candidate);
return candidate;
}
}
// Once the in-memory customer-domain set has loaded, the checks
// above are conclusive - no DB fallback needed.
if (this.allDomains.size > 0) {
this.authoritativeDomainCache.set(cacheKey, null);
return null;
}
@@ -582,7 +598,8 @@ export class AuthoritativeDNSServer {
private async getResourceRecordsByFullDomain(
name: string,
queryType: dns.RecordType
queryType: dns.RecordType,
baseDomain: string
): Promise<DNSRecord[]> {
const cacheKey = `resourceRecords:${name}:${queryType}`;
@@ -630,8 +647,14 @@ export class AuthoritativeDNSServer {
if (resourceRows.length === 0) {
// Resource doesn't exist at all — also pre-populate the domainExists
// cache so the subsequent domainExists() call hits memory, not the DB.
// Skip that pre-population when `name` is the zone apex itself: the
// apex always exists (it owns the SOA) even with no resource row, and
// caching `false` here would make domainExists() return that stale
// false instead of ever reaching its own apex check.
logger.debug(`No resource found for domain: ${name}`);
this.cache.set(`exists:${name}`, false, 60);
if (name.replace(/\.$/, "") !== baseDomain) {
this.cache.set(`exists:${name}`, false, 60);
}
this.cache.set(cacheKey, [], 60);
return [];
}
@@ -164,6 +164,7 @@ export async function listExitNodes(
endpoint: exitNodes.endpoint,
publicKey: exitNodes.publicKey,
listenPort: exitNodes.listenPort,
relayPort: exitNodes.relayPort,
reachableAt: exitNodes.reachableAt,
maxConnections: exitNodes.maxConnections,
online: exitNodes.online,
+27
View File
@@ -0,0 +1,27 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import type { OfflineThresholds } from "@server/lib/offlineThresholds";
import privateConfig from "#private/lib/config";
export function getOfflineThresholds(): OfflineThresholds {
const offlineChecker = privateConfig.getRawPrivateConfig().offline_checker;
return {
checkIntervalMs: offlineChecker.check_interval_ms,
newtMs: offlineChecker.newt_threshold_ms,
newtWireguardMs: offlineChecker.newt_wireguard_threshold_ms,
olmMs: offlineChecker.olm_threshold_ms,
remoteExitNodeMs: offlineChecker.remote_exit_node_threshold_ms
};
}
+41
View File
@@ -18,6 +18,7 @@ import { z } from "zod";
import { colorsSchema } from "@server/lib/colorsSchema";
import { build } from "@server/build";
import { getEnvOrYaml } from "@server/lib/getEnvOrYaml";
import { DEFAULT_OFFLINE_THRESHOLDS } from "@server/lib/offlineThresholds";
const portSchema = z.number().positive().gt(0).lte(65535);
@@ -109,6 +110,16 @@ export const privateConfigSchema = z
.array(z.string())
.optional()
.default([]),
// Zone names (zone cuts) this server is authoritative for
// beyond the customer domains stored in the `domains` table -
// e.g. a self-hosted deployment's own nameserver zone or
// cname/site extension zones. Any query landing on one of
// these names, or an empty non-terminal beneath one, that
// doesn't match a more specific record gets NOERROR/NODATA +
// SOA (not NXDOMAIN), so QNAME-minimising resolvers (RFC
// 9156) don't treat the ancestor as proof nothing exists
// below it (RFC 8020). Left for the user to populate.
zones: z.array(z.string()).optional().default([]),
rate_limit: z
.object({
enabled: z.boolean().optional().default(true),
@@ -166,6 +177,36 @@ export const privateConfigSchema = z
.default("http://gerbil:3004")
})
.prefault({}),
// How long a newt site / olm client / remote exit node has to go
// without a ping before the offline checker marks it offline, and how
// often the checkers run.
offline_checker: z
.object({
check_interval_ms: z
.number()
.int()
.positive()
.default(DEFAULT_OFFLINE_THRESHOLDS.checkIntervalMs),
newt_threshold_ms: z
.number()
.int()
.default(DEFAULT_OFFLINE_THRESHOLDS.newtMs),
// wireguard sites don't ping, so they are judged by their
// last bandwidth update instead
newt_wireguard_threshold_ms: z
.number()
.int()
.default(DEFAULT_OFFLINE_THRESHOLDS.newtWireguardMs),
olm_threshold_ms: z
.number()
.int()
.default(DEFAULT_OFFLINE_THRESHOLDS.olmMs),
remote_exit_node_threshold_ms: z
.number()
.int()
.default(DEFAULT_OFFLINE_THRESHOLDS.remoteExitNodeMs)
})
.prefault({}),
flags: z
.object({
enable_redis: z.boolean().optional().default(false),
+93 -2
View File
@@ -22,6 +22,7 @@ import {
domains,
exitNodes,
loginPage,
resourceMtlsCertificates,
redirects,
resources,
siteNetworks,
@@ -54,6 +55,7 @@ import { buildWildcardTls } from "@server/lib/traefik/certResolver";
import { buildCustomHeadersMiddleware } from "@server/lib/traefik/headersMiddleware";
import {
buildHttpLoadBalancerServers,
buildLoadBalancingStrategy,
buildStickySessionCookie,
buildStickySessionIp,
buildTcpUdpLoadBalancerServers
@@ -132,6 +134,7 @@ export async function getTraefikConfig(
domainId: resources.domainId,
enabled: resources.enabled,
stickySession: resources.stickySession,
loadBalancingStrategy: resources.loadBalancingStrategy,
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy,
@@ -148,6 +151,8 @@ export async function getTraefikConfig(
maintenanceMessage: resources.maintenanceMessage,
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
mtlsEnabled: resources.mtlsEnabled,
// Target fields
targetId: targets.targetId,
targetEnabled: targets.enabled,
@@ -274,6 +279,7 @@ export async function getTraefikConfig(
domainId: row.domainId,
enabled: row.enabled,
stickySession: row.stickySession,
loadBalancingStrategy: row.loadBalancingStrategy,
tlsServerName: row.tlsServerName,
setHostHeader: row.setHostHeader,
enableProxy: row.enableProxy,
@@ -295,7 +301,9 @@ export async function getTraefikConfig(
maintenanceModeType: row.maintenanceModeType,
maintenanceTitle: row.maintenanceTitle,
maintenanceMessage: row.maintenanceMessage,
maintenanceEstimatedTime: row.maintenanceEstimatedTime
maintenanceEstimatedTime: row.maintenanceEstimatedTime,
mtlsEnabled: row.mtlsEnabled
});
}
@@ -319,6 +327,33 @@ export async function getTraefikConfig(
});
}
// Trusted client-CA certificates for resources that require mTLS.
const mtlsResourceIds = [
...new Set(
[...resourcesMap.values()]
.filter((r) => r.mode === "http" && r.mtlsEnabled)
.map((r) => r.resourceId as number)
)
];
const mtlsCaCertsByResource = new Map<number, string[]>();
if (mtlsResourceIds.length > 0) {
const caRows = await db
.select({
resourceId: resourceMtlsCertificates.resourceId,
certificate: resourceMtlsCertificates.certificate
})
.from(resourceMtlsCertificates)
.where(
inArray(resourceMtlsCertificates.resourceId, mtlsResourceIds)
)
.orderBy(resourceMtlsCertificates.mtlsCertificateId);
for (const row of caRows) {
const list = mtlsCaCertsByResource.get(row.resourceId) ?? [];
list.push(row.certificate);
mtlsCaCertsByResource.set(row.resourceId, list);
}
}
// Group browser gateway targets by resource
const browserGatewayResourcesMap = browserGatewayUiUrl
? buildBrowserGatewayResourcesMap(
@@ -628,7 +663,7 @@ export async function getTraefikConfig(
resource.pathMatchType
);
let tls = {};
let tls: Record<string, any> = {};
if (!pangolinCertModeEnabled) {
tls = buildWildcardTls({
fullDomain,
@@ -650,9 +685,65 @@ export async function getTraefikConfig(
}
}
// mTLS: Traefik resolves TLS options per hostname, and routers on
// the same host that reference different options make it fall back
// to the defaults, so the options are keyed on the resource (not
// the per-path router key) and every TLS router below (including
// the maintenance ones) references the same name.
const mtlsCaCerts =
resource.mtlsEnabled && resource.ssl
? mtlsCaCertsByResource.get(resource.resourceId)
: undefined;
if (mtlsCaCerts && mtlsCaCerts.length > 0) {
const mtlsOptionsName = `resource-${resource.resourceId}-mtls`;
const mtlsHeadersMiddlewareName = `resource-${resource.resourceId}-mtls-headers`;
if (!config_output.tls) {
config_output.tls = {};
}
if (!config_output.tls.options) {
config_output.tls.options = {};
}
// caFiles entries are Traefik FileOrContent values: anything
// that isn't an existing file path is read as inline PEM, so
// the CA certs travel in this config to any exit node.
config_output.tls.options[mtlsOptionsName] = {
clientAuth: {
caFiles: mtlsCaCerts,
clientAuthType: "RequireAndVerifyClientCert"
}
};
config_output.http.middlewares[mtlsHeadersMiddlewareName] = {
passTLSClientCert: {
pem: false,
info: {
notAfter: true,
notBefore: true,
serialNumber: true,
sans: true,
subject: {
commonName: true,
organization: true
},
issuer: {
commonName: true,
organization: true
}
}
}
};
routerMiddlewares.unshift(mtlsHeadersMiddlewareName);
tls = { ...tls, options: mtlsOptionsName };
}
config_output.http.services![serviceName] = {
loadBalancer: {
servers: buildHttpLoadBalancerServers(targets),
...buildLoadBalancingStrategy(
resource.loadBalancingStrategy
),
...(resource.stickySession
? buildStickySessionCookie(resource.ssl)
: {})