Merge branch 'dev' into feat/redirect-tables

This commit is contained in:
Fred KISSIE
2026-09-18 20:59:18 +02:00
197 changed files with 11958 additions and 4383 deletions
+101
View File
@@ -0,0 +1,101 @@
import { formatBackupTimestamp } from "./backupFileName";
import { assertEquals } from "@test/assert";
// Local-time constructors are used throughout, matching formatBackupTimestamp,
// so these cases do not depend on the machine's timezone.
function testMonthIsOneIndexed() {
console.log("Running month indexing tests...");
// The case from the report: a backup taken on 12 September 2026 was named
// db_2026-8-12_... because Date#getMonth is zero-indexed.
{
const result = formatBackupTimestamp(new Date(2026, 8, 12, 20, 35, 56));
assertEquals(
result,
"2026-09-12_20-35-56",
"September must render as 09, not 8"
);
}
// The other reported name, db_2026-0-23_..., was a January backup.
{
const result = formatBackupTimestamp(new Date(2026, 0, 23, 20, 25, 49));
assertEquals(
result,
"2026-01-23_20-25-49",
"January must render as 01, not 0"
);
}
{
const result = formatBackupTimestamp(new Date(2026, 11, 31, 23, 59, 59));
assertEquals(
result,
"2026-12-31_23-59-59",
"December must render as 12"
);
}
}
function testEveryFieldIsZeroPadded() {
console.log("Running zero padding tests...");
// db_2026-8-12_20-36-2 in the report: a single-digit second was not padded.
{
const result = formatBackupTimestamp(new Date(2026, 8, 12, 20, 36, 2));
assertEquals(
result,
"2026-09-12_20-36-02",
"Single-digit seconds must be padded"
);
}
{
const result = formatBackupTimestamp(new Date(2026, 0, 1, 0, 0, 0));
assertEquals(
result,
"2026-01-01_00-00-00",
"Midnight on the first of the month must pad every field"
);
}
}
function testNamesSortChronologically() {
console.log("Running sort order tests...");
// Zero padding means a plain lexicographic sort of the backups directory
// lists the backups in the order they were taken.
const taken = [
new Date(2026, 8, 12, 20, 36, 2),
new Date(2026, 0, 23, 20, 25, 49),
new Date(2026, 8, 12, 20, 35, 56),
new Date(2026, 11, 31, 23, 59, 59)
];
const sorted = taken
.map((date) => formatBackupTimestamp(date))
.sort();
assertEquals(
sorted.join(","),
[
"2026-01-23_20-25-49",
"2026-09-12_20-35-56",
"2026-09-12_20-36-02",
"2026-12-31_23-59-59"
].join(","),
"Backup names must sort into the order the backups were taken"
);
}
// Run all tests
try {
testMonthIsOneIndexed();
testEveryFieldIsZeroPadded();
testNamesSortChronologically();
console.log("All tests passed successfully!");
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Builds the timestamp segment of a database backup file name.
*
* `Date#getMonth` is zero-indexed, so building this inline produced names like
* `db_2026-8-12_...` for a backup taken on 12 September 2026. Every field is
* also zero-padded, which keeps the names unambiguous and makes them sort
* lexicographically in the order they were taken.
*
* @param date The moment the backup is being taken. Defaults to now.
* @returns A timestamp of the form `YYYY-MM-DD_HH-MM-SS`.
*/
export function formatBackupTimestamp(date: Date = new Date()): string {
const pad = (value: number): string => String(value).padStart(2, "0");
const datePart = [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate())
].join("-");
const timePart = [
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds())
].join("-");
return `${datePart}_${timePart}`;
}
+6 -6
View File
@@ -1,6 +1,6 @@
export enum LicenseId {
SMALL_LICENSE = "small_license",
BIG_LICENSE = "big_license"
TIER1 = "tier1",
TIER2 = "tier2"
}
export type LicensePriceSet = {
@@ -9,15 +9,15 @@ export type LicensePriceSet = {
export const licensePriceSet: LicensePriceSet = {
// Free license matches the freeLimitSet
[LicenseId.SMALL_LICENSE]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT",
[LicenseId.BIG_LICENSE]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS"
[LicenseId.TIER1]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT",
[LicenseId.TIER2]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS"
};
export const licensePriceSetSandbox: LicensePriceSet = {
// Free license matches the freeLimitSet
// when matching license the keys closer to 0 index are matched first so list the licenses in descending order of value
[LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN",
[LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl"
[LicenseId.TIER1]: "price_1SxDwuDCpkOb237Bz0yTiOgN",
[LicenseId.TIER2]: "price_1SxDy0DCpkOb237BWJxrxYkl"
};
export function getLicensePriceSet(
+2 -8
View File
@@ -1,13 +1,7 @@
import NodeCache from "node-cache";
import logger from "@server/logger";
import { createLocalCache } from "@server/lib/createLocalCache";
// Create local cache with maxKeys limit to prevent memory leaks
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
export const localCache = new NodeCache({
stdTTL: 3600,
checkperiod: 120,
maxKeys: 10000
});
export const localCache = createLocalCache();
// Log cache statistics periodically for monitoring
// setInterval(() => {
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.22.0";
export const APP_VERSION = "1.23.0";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);
+82
View File
@@ -0,0 +1,82 @@
import { LRUCache } from "lru-cache";
const DEFAULT_MAX_KEYS = 10000;
const DEFAULT_TTL_SECONDS = 3600;
export type LocalCache = {
get<T>(key: string): T | undefined;
set(key: string, value: unknown, ttlSeconds?: number): boolean;
del(key: string | string[]): number;
has(key: string): boolean;
keys(): string[];
flushAll(): void;
getStats(): { keys: number };
getTtl(key: string): number | undefined;
};
export function createLocalCache(
max = DEFAULT_MAX_KEYS,
ttlSeconds = DEFAULT_TTL_SECONDS
): LocalCache {
const lru = new LRUCache<string, {}>({
max,
ttl: ttlSeconds * 1000,
updateAgeOnGet: false
});
return {
get<T>(key: string): T | undefined {
return lru.get(key) as T | undefined;
},
set(key: string, value: unknown, ttlSeconds?: number): boolean {
const stored = value as {};
if (ttlSeconds === undefined) {
lru.set(key, stored);
} else if (ttlSeconds === 0) {
lru.set(key, stored, { ttl: 0 });
} else {
lru.set(key, stored, { ttl: ttlSeconds * 1000 });
}
return true;
},
del(key: string | string[]): number {
const keys = Array.isArray(key) ? key : [key];
let deleted = 0;
for (const k of keys) {
if (lru.delete(k)) {
deleted++;
}
}
return deleted;
},
has(key: string): boolean {
return lru.has(key);
},
keys(): string[] {
return [...lru.keys()];
},
flushAll(): void {
lru.clear();
},
getStats(): { keys: number } {
return { keys: lru.size };
},
getTtl(key: string): number | undefined {
if (!lru.has(key)) {
return undefined;
}
const remaining = lru.getRemainingTTL(key);
if (!Number.isFinite(remaining)) {
return 0;
}
return Date.now() + remaining;
}
};
}
+10
View File
@@ -0,0 +1,10 @@
export function createCname(
domainId: string,
baseDomain: string
): { baseDomain: string; value: string }[] {
throw new Error("Creating CNAME records is not supported in this build");
}
export function createNs(): string[] {
throw new Error("Creating NS records is not supported in this build");
}
+14
View File
@@ -0,0 +1,14 @@
// Tracks, per process lifetime, whether a given exit node has ever checked in
// (called /gerbil/get-config) since this Pangolin instance started. This lets
// callers distinguish "gerbil hasn't come up yet" (expected briefly after a
// restart, since gerbil depends on pangolin's container starting first) from
// "gerbil was reachable and now isn't" (a real problem worth an error log).
const checkedInExitNodeIds = new Set<number>();
export function markExitNodeCheckedIn(exitNodeId: number): void {
checkedInExitNodeIds.add(exitNodeId);
}
export function hasExitNodeCheckedIn(exitNodeId: number): boolean {
return checkedInExitNodeIds.has(exitNodeId);
}
+13 -6
View File
@@ -1,6 +1,7 @@
import axios from "axios";
import logger from "@server/logger";
import { ExitNode } from "@server/db";
import { hasExitNodeCheckedIn } from "./exitNodeCheckIn";
interface ExitNodeRequest {
remoteType?: string;
@@ -72,13 +73,19 @@ export async function sendToExitNode(
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error(
`Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}`
);
const message = axios.isAxiosError(error)
? `Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}`
: `Error making ${method} request for exit node at ${exitNode.reachableAt}: ${error}`;
// The exit node (gerbil) may still be starting up and not yet
// reachable. Until it has checked in at least once, log this at a
// lower level since it's expected; once it has checked in, a
// connection failure is a real problem.
if (hasExitNodeCheckedIn(exitNode.exitNodeId)) {
logger.error(message);
} else {
logger.error(
`Error making ${method} request for exit node at ${exitNode.reachableAt}: ${error}`
logger.warn(
`${message} (exit node has not checked in yet since startup, this is expected briefly)`
);
}
throw error;
+1
View File
@@ -1,5 +1,6 @@
export * from "./exitNodes";
export * from "./exitNodeComms";
export * from "./exitNodeCheckIn";
export * from "./subnet";
export * from "./getCurrentExitNodeId";
export * from "./calculateExitNodeWeight";
+36 -2
View File
@@ -1,3 +1,37 @@
export const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
return process.env[envVar] ?? valFromYaml;
import fs from "fs";
// Resolves an environment variable, also honoring a `<envVar>_FILE` variant
// that points to a file whose (trimmed) contents should be used as the
// value. This is the common convention for consuming Docker/Swarm secrets
// (e.g. mounted at /run/secrets/...) without putting the raw value in the
// container's environment.
export const readEnvOrFile = (envVar: string): string | undefined => {
const fileEnvVar = `${envVar}_FILE`;
const filePath = process.env[fileEnvVar];
if (filePath) {
if (process.env[envVar]) {
throw new Error(
`Both ${envVar} and ${fileEnvVar} are set. Please set only one.`
);
}
try {
return fs.readFileSync(filePath, "utf8").trim();
} catch (error) {
throw new Error(
`Failed to read ${fileEnvVar} (${filePath}): ${
error instanceof Error ? error.message : error
}`
);
}
}
return process.env[envVar];
};
export const getEnvOrYaml =
(envVar: string) =>
(valFromYaml: string | undefined): string | undefined => {
return readEnvOrFile(envVar) ?? valFromYaml;
};
+15 -45
View File
@@ -3,7 +3,7 @@ import * as yaml from "js-yaml";
import { configFilePath1, configFilePath2 } from "./consts";
import { z } from "zod";
import stoi from "./stoi";
import { getEnvOrYaml } from "./getEnvOrYaml";
import { getEnvOrYaml, readEnvOrFile } from "./getEnvOrYaml";
const portSchema = z.number().positive().gt(0).lte(65535);
@@ -26,14 +26,12 @@ export const configSchema = z
.object({
anonymous_usage: z.boolean().optional().default(true)
})
.optional()
.prefault({}),
notifications: z
.object({
product_updates: z.boolean().optional().default(true),
new_releases: z.boolean().optional().default(true)
})
.optional()
.prefault({})
})
.optional()
@@ -109,7 +107,6 @@ export const configSchema = z
id: z.string().optional().default("P-Access-Token-Id"),
token: z.string().optional().default("P-Access-Token")
})
.optional()
.prefault({}),
remote_headers: z
.object({
@@ -126,7 +123,6 @@ export const configSchema = z
name: z.string().optional().default("Remote-Name"),
role: z.string().optional().default("Remote-Role")
})
.optional()
.prefault({}),
resource_session_request_param: z
.string()
@@ -164,14 +160,17 @@ export const configSchema = z
.boolean()
.optional()
.default(false)
.transform((val) =>
process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER !==
undefined
? process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER ===
"true"
: val
),
secret: z.string().pipe(z.string().min(8)).optional(),
.transform((val) => {
const envVal = readEnvOrFile(
"ENABLE_AI_GATEWAY_CLIENT_IP_HEADER"
);
return envVal !== undefined ? envVal === "true" : val;
}),
secret: z
.string()
.pipe(z.string().min(8))
.optional()
.transform(getEnvOrYaml("SERVER_SECRET")),
maxmind_db_path: z.string().optional(),
maxmind_asn_path: z.string().optional()
})
@@ -202,7 +201,8 @@ export const configSchema = z
dashboard_session_length_hours: 720,
resource_session_length_hours: 720,
trust_proxy: 1,
enable_ai_gateway_client_ip_header: false
enable_ai_gateway_client_ip_header: false,
secret: undefined
}),
postgres: z
.object({
@@ -238,7 +238,6 @@ export const configSchema = z
.default(5000),
jit_mode: z.boolean().default(true)
})
.optional()
.prefault({})
})
.optional(),
@@ -278,7 +277,6 @@ export const configSchema = z
.optional()
.default(5000)
})
.optional()
.prefault({})
})
.optional(),
@@ -325,10 +323,8 @@ export const configSchema = z
.optional()
.default(50)
})
.optional()
.prefault({})
})
.optional()
.prefault({}),
gerbil: z
.object({
@@ -357,7 +353,6 @@ export const configSchema = z
.optional()
.default(30)
})
.optional()
.prefault({}),
orgs: z
.object({
@@ -391,7 +386,6 @@ export const configSchema = z
.optional()
.default(500)
})
.optional()
.prefault({}),
auth: z
.object({
@@ -408,10 +402,8 @@ export const configSchema = z
.optional()
.default(500)
})
.optional()
.prefault({})
})
.optional()
.prefault({}),
email: z
.object({
@@ -489,27 +481,8 @@ export const configSchema = z
.optional()
.default(12)
})
.optional()
.prefault({})
})
.optional()
.prefault({}),
dns: z
.object({
nameservers: z
.array(z.string().optional().optional())
.optional()
.default([
"ns1.pangolin.net",
"ns2.pangolin.net",
"ns3.pangolin.net"
]),
cname_extension: z
.string()
.optional()
.default("cname.pangolin.net")
})
.optional()
.prefault({})
})
.refine(
@@ -530,10 +503,7 @@ export const configSchema = z
)
.refine(
(data) => {
// If hybrid is not defined, server secret must be defined. If its not defined already then pull it from env
if (data.server?.secret === undefined) {
data.server.secret = process.env.SERVER_SECRET;
}
// If hybrid is not defined, server secret must be defined
return (
data.server?.secret !== undefined &&
data.server.secret.length > 0
+72
View File
@@ -0,0 +1,72 @@
import logger from "@server/logger";
export async function withRetry<T>(
fn: () => Promise<T>,
options: {
retries?: number;
baseDelayMs?: number;
label?: string;
// Called with each caught error to decide whether it's worth
// retrying. Defaults to retrying everything (existing behavior) -
// pass this to exclude errors that are known to be permanent (e.g.
// an upstream rate limit or validation rejection) rather than
// transient, so they fail fast instead of wasting retry attempts.
shouldRetry?: (error: unknown) => boolean;
} = {}
): Promise<T> {
const {
retries = 3,
baseDelayMs = 250,
label = "operation",
shouldRetry = () => true
} = options;
let attempt = 0;
while (true) {
try {
return await fn();
} catch (error) {
attempt++;
if (attempt > retries || !shouldRetry(error)) {
throw error;
}
// Exponential backoff with jitter so retries don't all land at once.
const delay =
baseDelayMs * 2 ** (attempt - 1) * (0.5 + Math.random());
logger.warn(
`${label} failed (attempt ${attempt}/${retries + 1}), retrying in ${delay.toFixed(0)}ms`,
error
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
// Bounds an operation that has no timeout of its own (e.g. acme-client's
// axios instance never sets one, so a stalled TCP connection to the ACME
// server hangs forever instead of erroring). Without this, a single hung
// call can leave its caller's promise permanently unsettled - fatal for
// code that gates future work on that promise resolving, like the
// scheduler's runExclusive() waiting on a batch's Promise.all.
export async function withTimeout<T>(
promise: Promise<T>,
ms: number,
label = "operation"
): Promise<T> {
let timer: NodeJS.Timeout;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`${label} timed out after ${ms}ms`)),
ms
);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer!);
}
}
+91 -44
View File
@@ -6,11 +6,15 @@ import * as yaml from "js-yaml";
import axios from "axios";
import { db, exitNodes } from "@server/db";
import { eq } from "drizzle-orm";
import { getCurrentExitNodeId } from "@server/lib/exitNodes";
import {
getCurrentExitNodeId,
hasExitNodeCheckedIn
} from "@server/lib/exitNodes";
import { getTraefikConfig } from "#dynamic/lib/traefik";
import { getValidCertificatesForDomains } from "@server/lib/certificates";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
import license from "#dynamic/license/license";
export class TraefikConfigManager {
private intervalId: NodeJS.Timeout | null = null;
@@ -341,10 +345,6 @@ export class TraefikConfigManager {
const { domains, traefikConfig } = getTraefikConfig;
// Add static domains from config
// const staticDomains = [config.getRawConfig().app.dashboard_url];
// staticDomains.forEach((domain) => domains.add(domain));
// Log if domains changed
if (
this.lastActiveDomains.size !== domains.size ||
@@ -358,7 +358,11 @@ export class TraefikConfigManager {
this.lastActiveDomains = new Set(domains);
}
if (process.env.USE_PANGOLIN_DNS === "true" && build != "oss") {
if (
process.env.CERT_MODE === "pangolin" &&
build != "oss" &&
(await license.hasTier(["personal", "tier2", "enterprise"]))
) {
// Scan current local certificate state
this.lastLocalCertificateState =
await this.scanLocalCertificateState();
@@ -439,13 +443,13 @@ export class TraefikConfigManager {
// Always ensure all existing certificates (including wildcards) are in the config
await this.updateDynamicConfigFromLocalCerts(domains);
} else {
const timeSinceLastFetch = this.lastCertificateFetch
? Math.round(
(Date.now() -
this.lastCertificateFetch.getTime()) /
(1000 * 60)
)
: 0;
// const timeSinceLastFetch = this.lastCertificateFetch
// ? Math.round(
// (Date.now() -
// this.lastCertificateFetch.getTime()) /
// (1000 * 60)
// )
// : 0;
// logger.debug(
// `Skipping certificate fetch - no changes detected and within 24-hour window (last fetch: ${timeSinceLastFetch} minutes ago)`
@@ -466,32 +470,51 @@ export class TraefikConfigManager {
await this.writeTraefikDynamicConfig(traefikConfig);
// Send domains to SNI proxy
let exitNodeForSni: typeof exitNodes.$inferSelect | undefined;
try {
let exitNode;
if (config.getRawConfig().gerbil.exit_node_name) {
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name!;
[exitNode] = await db
[exitNodeForSni] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.name, exitNodeName))
.limit(1);
} else {
[exitNode] = await db.select().from(exitNodes).limit(1);
[exitNodeForSni] = await db
.select()
.from(exitNodes)
.limit(1);
}
if (exitNode) {
await sendToExitNode(exitNode, {
if (exitNodeForSni) {
await sendToExitNode(exitNodeForSni, {
localPath: "/update-local-snis",
method: "POST",
data: { fullDomains: Array.from(domains) }
data: {
fullDomains: [
...Array.from(domains),
...config.getRawConfig().traefik.static_domains
]
}
});
} else {
logger.error(
logger.warn(
"No exit node found. Has gerbil registered yet?"
);
}
} catch (err) {
logger.error("Failed to post domains to SNI proxy:", err);
// sendToExitNode already logs the underlying connection
// error at the appropriate level (warn before the exit node
// has checked in since startup, error after), so avoid
// double-logging it as an error here.
if (
exitNodeForSni &&
!hasExitNodeCheckedIn(exitNodeForSni.exitNodeId)
) {
logger.warn("Failed to post domains to SNI proxy:", err);
} else {
logger.error("Failed to post domains to SNI proxy:", err);
}
}
// Update active domains tracking
@@ -699,10 +722,9 @@ export class TraefikConfigManager {
}
if (shouldWrite) {
try {
fs.writeFileSync(
this.atomicWriteFileSync(
traefikDynamicConfigPath,
yaml.dump(traefikConfig, { noRefs: true }),
"utf8"
yaml.dump(traefikConfig, { noRefs: true })
);
logger.info("Traefik dynamic config updated");
} catch (err) {
@@ -804,7 +826,7 @@ export class TraefikConfigManager {
// Only write the config if it has changed
const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true });
if (newConfigYaml !== originalConfigYaml) {
fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8");
this.atomicWriteFileSync(dynamicConfigPath, newConfigYaml);
logger.info("Dynamic cert config updated from local certificates");
}
}
@@ -882,26 +904,23 @@ export class TraefikConfigManager {
`Processing certificate for domain: ${cert.domain}`
);
fs.writeFileSync(certPath, cert.certFile, "utf8");
fs.writeFileSync(keyPath, cert.keyFile, "utf8");
// Set appropriate permissions (readable by owner only for key file)
fs.chmodSync(certPath, 0o644);
fs.chmodSync(keyPath, 0o600);
// Write atomically (temp file + rename) so Traefik's
// file watcher never observes a partially written
// cert/key and fails with "failed to find any PEM data".
this.atomicWriteFileSync(certPath, cert.certFile, 0o644);
this.atomicWriteFileSync(keyPath, cert.keyFile, 0o600);
// Write/update .last_update file with current timestamp
fs.writeFileSync(
this.atomicWriteFileSync(
lastUpdatePath,
new Date().toISOString(),
"utf8"
new Date().toISOString()
);
// Check if this is a wildcard certificate and store it
const wildcardPath = path.join(domainDir, ".wildcard");
fs.writeFileSync(
this.atomicWriteFileSync(
wildcardPath,
cert.wildcard ? "true" : "false",
"utf8"
cert.wildcard ? "true" : "false"
);
logger.info(
@@ -913,10 +932,9 @@ export class TraefikConfigManager {
// even if the cert content didn't change
if (cert.expiresAt) {
const expiresAtPath = path.join(domainDir, ".expires_at");
fs.writeFileSync(
this.atomicWriteFileSync(
expiresAtPath,
cert.expiresAt.toString(),
"utf8"
cert.expiresAt.toString()
);
}
@@ -952,7 +970,7 @@ export class TraefikConfigManager {
// Only write the config if it has changed
const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true });
if (newConfigYaml !== originalConfigYaml) {
fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8");
this.atomicWriteFileSync(dynamicConfigPath, newConfigYaml);
logger.info("Dynamic cert config updated");
}
}
@@ -1123,10 +1141,9 @@ export class TraefikConfigManager {
if (configChanged) {
try {
fs.writeFileSync(
this.atomicWriteFileSync(
dynamicConfigPath,
yaml.dump(dynamicConfig, { noRefs: true }),
"utf8"
yaml.dump(dynamicConfig, { noRefs: true })
);
logger.info("Dynamic config updated after cleanup");
} catch (err) {
@@ -1153,6 +1170,36 @@ export class TraefikConfigManager {
}
}
/**
* Write a file atomically by writing to a temp file in the same
* directory and renaming it into place. This avoids Traefik (which
* watches these files/directories) picking up a partially written
* file and failing to parse it (e.g. "failed to find any PEM data").
*/
private atomicWriteFileSync(
filePath: string,
data: string,
mode?: number
): void {
const dir = path.dirname(filePath);
const tmpPath = path.join(
dir,
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
try {
fs.writeFileSync(tmpPath, data, "utf8");
if (mode !== undefined) {
fs.chmodSync(tmpPath, mode);
}
fs.renameSync(tmpPath, filePath);
} catch (error) {
try {
fs.rmSync(tmpPath, { force: true });
} catch {}
throw error;
}
}
/**
* Check if file exists
*/
+6 -12
View File
@@ -1,7 +1,5 @@
/**
* Build the Host()/HostRegexp() Traefik rule for a resource's domain.
* Wildcard resources match any single subdomain via HostRegexp.
*/
// Build the Host()/HostRegexp() Traefik rule for a resource's domain.
// Wildcard resources match any single subdomain via HostRegexp.
export function buildHostRule(
fullDomain: string,
wildcard?: boolean | null
@@ -14,10 +12,8 @@ export function buildHostRule(
return `Host(\`${fullDomain}\`)`;
}
/**
* Append a path-matching clause to a Traefik rule based on the resource's
* configured path and pathMatchType.
*/
// Append a path-matching clause to a Traefik rule based on the resource's
// configured path and pathMatchType.
export function appendPathMatch(
rule: string,
path: string | null | undefined,
@@ -82,10 +78,8 @@ export function matchesPath(
return true;
}
/**
* Compute the router priority for a resource, favoring an explicit override
* and otherwise deriving it from the path match specificity.
*/
// Compute the router priority for a resource, favoring an explicit override
// and otherwise deriving it from the path match specificity.
export function computeRoutePriority(
priority: number | null | undefined,
path: string | null | undefined,