diff --git a/server/index.ts b/server/index.ts index a61daca7..0e6e9dae 100644 --- a/server/index.ts +++ b/server/index.ts @@ -23,6 +23,7 @@ import { initCleanup } from "#dynamic/cleanup"; import license from "#dynamic/license/license"; import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; import { fetchServerIp } from "@server/lib/serverIpService"; +import { startMaintenanceServer } from "./lib/traefik/maintenance-server.js"; async function startServers() { await setHostMeta(); @@ -55,6 +56,8 @@ async function startServers() { integrationServer = createIntegrationApiServer(); } + startMaintenanceServer(); + await initCleanup(); return { diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index da567820..e8fcf248 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -216,10 +216,9 @@ export const configSchema = z .default(["newt", "wireguard", "local"]), allow_raw_resources: z.boolean().optional().default(true), file_mode: z.boolean().optional().default(false), - pp_transport_prefix: z - .string() - .optional() - .default("pp-transport-v") + pp_transport_prefix: z.string().optional().default("pp-transport-v"), + maintenance_host: z.string().optional(), + maintenance_port: z.number().optional().default(8888) }) .optional() .prefault({}), diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index dc5e0081..9cd81293 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -19,6 +19,109 @@ import { sanitize, validatePathRewriteConfig } from "./utils"; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; + +function escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, (char) => map[char]); +} + + +export function generateMaintenanceHTML( + title: string | null, + message: string | null, + estimatedTime: string | null +): string { + const safeTitle = escapeHtml(title || 'Service Temporarily Unavailable'); + const safeMessage = escapeHtml(message || 'We are currently experiencing technical difficulties. Please check back soon.'); + const safeEstimatedTime = estimatedTime ? escapeHtml(estimatedTime) : null; + + return ` + + + + + + ${safeTitle} + + + +
+
🔧
+

${safeTitle}

+

${safeMessage}

+ ${safeEstimatedTime ? + `
+ Estimated completion:
+ ${safeEstimatedTime} +
` + : ''} +
+ +`; +} + export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], @@ -59,6 +162,12 @@ 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, @@ -181,10 +290,14 @@ export async function getTraefikConfig( // Store domain cert resolver fields domainCertResolver: row.domainCertResolver, preferWildcardCert: row.preferWildcardCert + maintenanceModeEnabled: row.maintenanceModeEnabled, + maintenanceModeType: row.maintenanceModeType, + maintenanceTitle: row.maintenanceTitle, + maintenanceMessage: row.maintenanceMessage, + maintenanceEstimatedTime: row.maintenanceEstimatedTime, }); } - // Add target with its associated site data resourcesMap.get(key).targets.push({ resourceId: row.resourceId, targetId: row.targetId, @@ -247,6 +360,98 @@ export async function getTraefikConfig( config_output.http.services = {}; } + // available healthy servers for automatic mode + const availableServers = (targets as TargetWithSite[]).filter( + (target: TargetWithSite) => { + if (!target.enabled) return false; + + const anySitesOnline = (targets as TargetWithSite[]).some( + (t: TargetWithSite) => t.site.online + ); + if (anySitesOnline && !target.site.online) return false; + + if (target.site.type === "local" || target.site.type === "wireguard") { + return target.ip && target.port && target.method; + } else if (target.site.type === "newt") { + return target.internalPort && target.method && target.site.subnet; + } + return false; + } + ); + + const hasHealthyServers = availableServers.length > 0; + + let showMaintenancePage = false; + if (resource.maintenanceModeEnabled) { + if (resource.maintenanceModeType === "forced") { + showMaintenancePage = true; + logger.info( + `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 routerName = `${key}-maintenance-router`; + + const maintenancePort = config.getRawConfig().traefik.maintenance_port || 8888; + 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 tls = { + certResolver: resource.domainCertResolver?.trim() || + config.getRawConfig().traefik.cert_resolver, + ...(resource.preferWildcardCert ?? config.getRawConfig().traefik.prefer_wildcard_cert + ? { domains: [{ main: wildCard }] } + : {}) + }; + + const maintenanceHost = config.getRawConfig().traefik?.maintenance_host || 'pangolin'; + + config_output.http.services[maintenanceServiceName] = { + loadBalancer: { + servers: [{ url: `http://${maintenanceHost}:${maintenancePort}` }], + passHostHeader: true + } + }; + + const rule = `Host(\`${fullDomain}\`)`; + + config_output.http.routers[routerName] = { + entryPoints: [resource.ssl ? entrypointHttps : entrypointHttp], + service: maintenanceServiceName, + rule, + priority: 2000, + ...(resource.ssl ? { tls } : {}) + }; + + if (resource.ssl) { + config_output.http.routers[`${routerName}-redirect`] = { + entryPoints: [entrypointHttp], + middlewares: [redirectHttpsMiddlewareName], + service: maintenanceServiceName, + rule, + priority: 2000 + }; + } + + continue; + } + const domainParts = fullDomain.split("."); let wildCard; if (domainParts.length <= 2) { @@ -289,12 +494,12 @@ export async function getTraefikConfig( certResolver: resolverName, ...(preferWildcard ? { - domains: [ - { - main: wildCard - } - ] - } + domains: [ + { + main: wildCard + } + ] + } : {}) }; @@ -535,14 +740,14 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: resource.ssl, + httpOnly: true + } + } + } : {}) } }; @@ -645,18 +850,18 @@ export async function getTraefikConfig( })(), ...(resource.proxyProtocol && protocol == "tcp" ? { - serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues? - } + serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues? + } : {}), ...(resource.stickySession ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } : {}) } }; diff --git a/server/lib/traefik/maintenance-server.ts b/server/lib/traefik/maintenance-server.ts new file mode 100644 index 00000000..6fed317f --- /dev/null +++ b/server/lib/traefik/maintenance-server.ts @@ -0,0 +1,112 @@ +import express from 'express'; +import { db, resources } from '@server/db'; +import { eq } from 'drizzle-orm'; +import { generateMaintenanceHTML } from './getTraefikConfig'; +import config from '@server/lib/config'; +import logger from '@server/logger'; +import path from 'path'; +import fs from 'fs'; + +const MAINTENANCE_DIR = path.join(process.cwd(), 'maintenance-pages'); + +if (!fs.existsSync(MAINTENANCE_DIR)) { + fs.mkdirSync(MAINTENANCE_DIR, { recursive: true }); +} + +export async function generateMaintenanceFiles() { + logger.info('Regenerating maintenance page files'); + + const maintenanceResources = await db + .select() + .from(resources) + .where(eq(resources.maintenanceModeEnabled, true)); + + // Clear old files + const files = fs.readdirSync(MAINTENANCE_DIR); + files.forEach(file => { + if (file.startsWith('maintenance-')) { + fs.unlinkSync(path.join(MAINTENANCE_DIR, file)); + } + }); + + // Generate new files + for (const resource of maintenanceResources) { + if (resource.fullDomain && resource.http) { + const html = generateMaintenanceHTML( + resource.maintenanceTitle, + resource.maintenanceMessage, + resource.maintenanceEstimatedTime + ); + + const filename = `maintenance-${resource.fullDomain}.html`; + const filepath = path.join(MAINTENANCE_DIR, filename); + + fs.writeFileSync(filepath, html, 'utf-8'); + logger.info(`Generated maintenance page: ${filename}`); + } + } +} + +export function startMaintenanceServer() { + const app = express(); + + app.use(express.static(MAINTENANCE_DIR, { + setHeaders: (res) => { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + } + })); + + + app.use(async (req, res) => { + const host = req.headers.host; + + if (!host) { + return res.status(400).send("Missing Host header"); + } + + const maintenanceFile = path.join(MAINTENANCE_DIR, `maintenance-${host}.html`); + + if (fs.existsSync(maintenanceFile)) { + res.status(503) + .header('Content-Type', 'text/html; charset=utf-8') + .header('Retry-After', '3600') + .sendFile(maintenanceFile); + } else { + try { + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.fullDomain, host)); + + if (resource?.maintenanceModeEnabled) { + const html = generateMaintenanceHTML( + resource.maintenanceTitle, + resource.maintenanceMessage, + resource.maintenanceEstimatedTime + ); + + return res.status(503) + .header('Content-Type', 'text/html; charset=utf-8') + .header('Retry-After', '3600') + .send(html); + } + } catch (error) { + logger.error(`Error serving maintenance page: ${error}`); + } + + res.status(404).send('Not found'); + } + }); + + const port = config.getRawConfig().traefik?.maintenance_port || 8888; + + app.listen(port, '0.0.0.0', () => { + logger.info(`Maintenance server listening on ${port}`); + + generateMaintenanceFiles().catch(err => { + logger.error(`Failed to generate initial maintenance files: ${err}`); + }); + }); +} \ No newline at end of file