diff --git a/server/index.ts b/server/index.ts index 7389242a..a61daca7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -23,7 +23,6 @@ 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 "./maintenance/maintenance-server.js"; async function startServers() { await setHostMeta(); @@ -56,8 +55,6 @@ async function startServers() { integrationServer = createIntegrationApiServer(); } - startMaintenanceServer(); - await initCleanup(); return { diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index e8fcf248..657fc987 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -218,7 +218,7 @@ export const configSchema = z file_mode: z.boolean().optional().default(false), pp_transport_prefix: z.string().optional().default("pp-transport-v"), maintenance_host: z.string().optional(), - maintenance_port: z.number().optional().default(8888) + maintenance_port: z.number().optional().default(3002) }) .optional() .prefault({}), diff --git a/server/maintenance/maintenance-server.ts b/server/maintenance/maintenance-server.ts deleted file mode 100644 index efa5a874..00000000 --- a/server/maintenance/maintenance-server.ts +++ /dev/null @@ -1,111 +0,0 @@ -import express from 'express'; -import { db, resources } from '@server/db'; -import { eq } from 'drizzle-orm'; -import { generateMaintenanceHTML } from './maintenanceUI'; -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.replace(/\./g, '_')}.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 hostname = host.split(':')[0]; - const maintenanceFile = path.join(MAINTENANCE_DIR, `maintenance-${hostname}.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, hostname)); - - 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 diff --git a/server/maintenance/maintenanceUI.ts b/server/maintenance/maintenanceUI.ts deleted file mode 100644 index 9096e6e3..00000000 --- a/server/maintenance/maintenanceUI.ts +++ /dev/null @@ -1,102 +0,0 @@ - -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} -
` - : ''} -
- -`; -} \ No newline at end of file diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index cba61d11..db9ae89f 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -87,6 +87,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, @@ -220,7 +227,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, }); } @@ -348,8 +361,8 @@ export async function getTraefikConfig( if (showMaintenancePage) { const maintenanceServiceName = `${key}-maintenance-service`; const maintenanceRouterName = `${key}-maintenance-router`; + const rewriteMiddlewareName = `${key}-maintenance-rewrite`; - const maintenancePort = config.getRawConfig().traefik.maintenance_port || 8888; const entrypointHttp = config.getRawConfig().traefik.http_entrypoint; const entrypointHttps = config.getRawConfig().traefik.https_entrypoint; @@ -367,7 +380,8 @@ export async function getTraefikConfig( : {}) }; - const maintenanceHost = config.getRawConfig().traefik?.maintenance_host || 'pangolin'; + const maintenancePort = config.getRawConfig().traefik?.maintenance_port; + const maintenanceHost = config.getRawConfig().traefik?.maintenance_host || 'dev_pangolin'; config_output.http.services[maintenanceServiceName] = { loadBalancer: { @@ -376,20 +390,32 @@ export async function getTraefikConfig( } }; + // middleware to rewrite path to /maintenance-screen + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + + config_output.http.middlewares[rewriteMiddlewareName] = { + replacePath: { + path: "/maintenance-screen" + } + }; + const rule = `Host(\`${fullDomain}\`)`; config_output.http.routers[maintenanceRouterName] = { entryPoints: [resource.ssl ? entrypointHttps : entrypointHttp], service: maintenanceServiceName, + middlewares: [rewriteMiddlewareName], rule, priority: 2000, ...(resource.ssl ? { tls } : {}) }; if (resource.ssl) { - config_output.http.routers[`${maintenanceRouterName}-redirect`] = { + config_output.http.routers[`${maintenanceRouterName}-redirect`] = { entryPoints: [entrypointHttp], - middlewares: [redirectHttpsMiddlewareName], + middlewares: [redirectHttpsMiddlewareName, rewriteMiddlewareName], service: maintenanceServiceName, rule, priority: 2000 @@ -398,7 +424,6 @@ export async function getTraefikConfig( continue; } - const domainParts = fullDomain.split("."); let wildCard; if (domainParts.length <= 2) { @@ -885,7 +910,7 @@ export async function getTraefikConfig( servers: [ { url: `http://${config.getRawConfig().server - .internal_hostname + .internal_hostname }:${config.getRawConfig().server.next_port}` } ] diff --git a/src/app/maintenance-screen/page.tsx b/src/app/maintenance-screen/page.tsx new file mode 100644 index 00000000..b700b65b --- /dev/null +++ b/src/app/maintenance-screen/page.tsx @@ -0,0 +1,53 @@ +import { headers } from 'next/headers'; +import { db } from '@server/db'; +import { resources } from '@server/db'; +import { eq } from 'drizzle-orm'; + + +export default async function MaintenanceScreen() { + const headersList = await headers(); + const host = headersList.get('host') || ''; + const hostname = host.split(':')[0]; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.fullDomain, hostname)) + .limit(1); + + + const title = resource?.maintenanceTitle || 'Service Temporarily Unavailable'; + const message = resource?.maintenanceMessage || 'We are currently experiencing technical difficulties. Please check back soon.'; + const estimatedTime = resource?.maintenanceEstimatedTime; + + return ( +
+
+
+
+ 🔧 +
+ +

+ {title} +

+ +

+ {message} +

+ + {estimatedTime && ( +
+

+ Estimated completion: +

+

+ {estimatedTime} +

+
+ )} +
+
+
+ ); +}