mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-29 06:59:06 +01:00
Merge branch 'dev' into feat/redirect-tables
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
export * from "./exitNodes";
|
||||
export * from "./exitNodeComms";
|
||||
export * from "./exitNodeCheckIn";
|
||||
export * from "./subnet";
|
||||
export * from "./getCurrentExitNodeId";
|
||||
export * from "./calculateExitNodeWeight";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user