From 260822ade5abeedfc9e9ad1bca23f5777b6dfbc1 Mon Sep 17 00:00:00 2001 From: 7heMech <83923848+7heMech@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:37:42 +0000 Subject: [PATCH 1/2] Trust Cloudflare visitor IPs for maintenance bypasses --- addons/maintenance/action.ts | 99 +++++++++++++++----------- addons/maintenance/app/index.ts | 26 +++++-- addons/maintenance/app/service.ts | 16 +++-- addons/maintenance/app/views.client.js | 16 +++++ addons/maintenance/app/views.css | 2 + addons/maintenance/app/views.ts | 19 +++-- cli/inject.ts | 85 ++++++++++++++++++++-- docs/decisions/maintenance.md | 42 +++++++---- lib/gateway-protocol.ts | 1 + tests/test-maintenance.test.ts | 78 ++++++++++++++++---- tests/test-shadow-embed.test.ts | 2 +- tools/preview-ui.ts | 2 +- 12 files changed, 296 insertions(+), 92 deletions(-) diff --git a/addons/maintenance/action.ts b/addons/maintenance/action.ts index 888e677..27ecec5 100644 --- a/addons/maintenance/action.ts +++ b/addons/maintenance/action.ts @@ -24,6 +24,11 @@ export interface MaintenanceStatus { bypasses: string[]; } +export interface GlobalMaintenanceStatus { + global: boolean; + bypasses: string[]; +} + export interface MaintenanceActionPaths { dataDir: string; lockDir: string; @@ -62,7 +67,8 @@ type MaintenanceVerb = | "set-bypass" | "global-status" | "global-enable" - | "global-disable"; + | "global-disable" + | "global-set-bypass"; interface ParsedAction { verb: MaintenanceVerb; @@ -86,14 +92,14 @@ function parseAction(argv: string[], paths: MaintenanceActionPaths, options: Mai "status", "enable", "disable", "get-template", "set-template", "reset-template", "set-bypass", ]; const globalAllowed: MaintenanceVerb[] = [ - "global-status", "global-enable", "global-disable", + "global-status", "global-enable", "global-disable", "global-set-bypass", ]; if (verb && globalAllowed.includes(verb)) { if (argv.length > 1) failAction(`action ${verb} takes no arguments`); return { verb, domain: "" }; } if (!verb || !siteAllowed.includes(verb)) { - failAction("usage: clp-addons action maintenance {status|enable|disable|get-template|set-template|reset-template|set-bypass} --domain | {global-status|global-enable|global-disable}"); + failAction("usage: clp-addons action maintenance {status|enable|disable|get-template|set-template|reset-template|set-bypass} --domain | {global-status|global-enable|global-disable|global-set-bypass}"); } let domain = ""; for (let i = 1; i < argv.length; i++) { @@ -200,6 +206,46 @@ function bypasses(path: string): string[] { .sort((a, b) => a.localeCompare(b)); } +async function replaceBypasses( + paths: MaintenanceActionPaths, domain: string, options: MaintenanceActionOptions, +): Promise { + const raw = await readInput(options); + if (Buffer.byteLength(raw, "utf8") > 16 * 1024) failAction("the bypass request is too large"); + let values: unknown; + try { + const parsed = JSON.parse(raw) as { ips?: unknown }; + values = parsed.ips; + } catch { + failAction("the bypass list must be JSON"); + } + if (!Array.isArray(values)) failAction("the bypass list must contain an ips array"); + if (values.length > MAX_BYPASS_IPS) failAction(`at most ${MAX_BYPASS_IPS} bypass addresses are allowed`); + const ips = [...new Set(values.map(normalizeIp))].sort((a, b) => a.localeCompare(b)); + const dir = siteDir(paths, domain, true); + assertDirectory(paths.lockDir); + mkdirSync(paths.lockDir, { recursive: true, mode: 0o700 }); + chmodSync(paths.lockDir, 0o700); + const lockKey = Bun.CryptoHasher.hash("sha256", domain, "hex"); + return withFileLock( + join(paths.lockDir, `maintenance-${lockKey}.lock`), + 10, + `another maintenance update is running for ${domain}`, + async () => { + const stage = mkdtempSync(join(dir, ".bypass-stage-")); + try { + for (const ip of ips) (options.writeAtomicFn ?? writeAtomic)(join(stage, ip), "", 0o600); + for (const name of readdirSync(dir)) { + if (name.startsWith("bypass_")) rmSync(join(dir, name), { force: true }); + } + for (const ip of ips) renameSync(join(stage, ip), join(dir, `bypass_${ip}`)); + } finally { + rmSync(stage, { recursive: true, force: true }); + } + return bypasses(dir); + }, + ); +} + export function maintenanceStatus(paths: MaintenanceActionPaths, domain: string): MaintenanceStatus { assertPanelSite(paths, domain); const dir = siteDir(paths, domain); @@ -318,8 +364,13 @@ export async function executeMaintenanceAction( const { verb, domain } = parseAction(argv, paths, options); if (verb === "global-status") { - const onPath = join(paths.dataDir, "_global", "on"); - return { global: existsSync(onPath) && safeRegularFile(onPath) }; + const dir = siteDir(paths, "_global"); + return { global: safeRegularFile(join(dir, "on"), 0), bypasses: bypasses(dir) }; + } + + if (verb === "global-set-bypass") { + const updated = await replaceBypasses(paths, "_global", options); + return { global: safeRegularFile(join(paths.dataDir, "_global", "on"), 0), bypasses: updated }; } if (verb === "global-enable" || verb === "global-disable") { @@ -416,42 +467,8 @@ export async function executeMaintenanceAction( return { domain, custom: false, html: DEFAULT_MAINTENANCE_PAGE }; } - const raw = await readInput(options); - if (Buffer.byteLength(raw, "utf8") > 16 * 1024) failAction("the bypass request is too large"); - let values: unknown; - try { - const parsed = JSON.parse(raw) as { ips?: unknown }; - values = parsed.ips; - } catch { - failAction("the bypass list must be JSON"); - } - if (!Array.isArray(values)) failAction("the bypass list must contain an ips array"); - if (values.length > MAX_BYPASS_IPS) failAction(`at most ${MAX_BYPASS_IPS} bypass addresses are allowed`); - const ips = [...new Set(values.map(normalizeIp))].sort((a, b) => a.localeCompare(b)); - const dir = siteDir(paths, domain, true); - assertDirectory(paths.lockDir); - mkdirSync(paths.lockDir, { recursive: true, mode: 0o700 }); - chmodSync(paths.lockDir, 0o700); - const lockKey = Bun.CryptoHasher.hash("sha256", domain, "hex"); - return withFileLock( - join(paths.lockDir, `maintenance-${lockKey}.lock`), - 10, - `another maintenance update is running for ${domain}`, - async () => { - const stage = mkdtempSync(join(dir, ".bypass-stage-")); - try { - // Complete every fallible write before changing the active set. - for (const ip of ips) (options.writeAtomicFn ?? writeAtomic)(join(stage, ip), "", 0o600); - for (const name of readdirSync(dir)) { - if (name.startsWith("bypass_")) rmSync(join(dir, name), { force: true }); - } - for (const ip of ips) renameSync(join(stage, ip), join(dir, `bypass_${ip}`)); - } finally { - rmSync(stage, { recursive: true, force: true }); - } - return maintenanceStatus(paths, domain); - }, - ); + await replaceBypasses(paths, domain, options); + return maintenanceStatus(paths, domain); } export async function runMaintenanceAction( diff --git a/addons/maintenance/app/index.ts b/addons/maintenance/app/index.ts index 187f30d..f0e38ea 100644 --- a/addons/maintenance/app/index.ts +++ b/addons/maintenance/app/index.ts @@ -28,7 +28,10 @@ function fragmentJson(body: unknown, csrf: string, status = 200): Response { } function clientIp(req: Request): string { - const candidate = (req.headers.get("x-real-ip") ?? req.headers.get("x-forwarded-for")?.split(",")[0] ?? "").trim(); + // For the convenience button, prefer the visitor address Cloudflare sends. + // The public Nginx bypass independently checks the connection peer before + // using this header, so the button itself grants nothing. + const candidate = (req.headers.get("cf-connecting-ip") ?? req.headers.get("x-real-ip") ?? req.headers.get("x-forwarded-for")?.split(",")[0] ?? "").trim(); return isIP(candidate) ? candidate : ""; } @@ -71,8 +74,8 @@ export async function handle( } } try { - const globalEnabled = await maintenanceService.globalStatus(); - if (!selected) return html(layout("Maintenance Mode", fleetView(await maintenanceService.listSites(), globalEnabled), updateNotice), csrf); + const globalStatus = await maintenanceService.globalStatus(); + if (!selected) return html(layout("Maintenance Mode", fleetView(await maintenanceService.listSites(), globalStatus), updateNotice), csrf); const domain = validateDomain(selected); if (!domain) return html(layout("Invalid site", '
That is not a valid hostname.
', updateNotice), csrf, 400); const [page, template] = await Promise.all([maintenanceService.site(domain), maintenanceService.template(domain)]); @@ -80,7 +83,7 @@ export async function handle( return html( layout( `Maintenance — ${domain}`, - siteView(page.site, template.data, clientIp(req), globalEnabled), + siteView(page.site, template.data, clientIp(req), globalStatus.global), updateNotice, page.context, ), @@ -100,7 +103,7 @@ export async function handle( const domain = validateDomain(url.searchParams.get("domain") ?? ""); if (!domain) return json({ ok: false, error: "that is not a valid hostname" }, 400); try { - const globalEnabled = await maintenanceService.globalStatus(); + const globalEnabled = (await maintenanceService.globalStatus()).global; const [page, template] = await Promise.all([maintenanceService.site(domain), maintenanceService.template(domain)]); if (!template.ok || !template.data) throw new Error(template.error ?? "maintenance template unavailable"); return fragmentJson( @@ -124,6 +127,19 @@ export async function handle( return json({ ok: true, data: { global: body.enabled } }, 200); } + if (method === "PUT" && path === "/api/global-bypasses") { + const denied = guardMutation(req); + if (denied) return denied; + let body: Record; + try { body = await readJsonObject(req, 16 * 1024); } catch (error) { return bodyErrorResponse(error); } + const ips = body.ips; + if (!Array.isArray(ips) || ips.length > MAX_BYPASS_IPS || ips.some((ip: unknown) => typeof ip !== "string")) { + return json({ ok: false, error: `ips must contain at most ${MAX_BYPASS_IPS} addresses` }, 400); + } + const result = await maintenanceService.setGlobalBypasses(ips as string[]); + return json(result, result.ok ? 200 : 400); + } + if (method === "POST" && (path === "/api/sites/toggle" || path === "/api/toggle-all" || path === "/api/bulk-toggle")) { const denied = guardMutation(req); if (denied) return denied; diff --git a/addons/maintenance/app/service.ts b/addons/maintenance/app/service.ts index 0f5e84c..428e6ec 100644 --- a/addons/maintenance/app/service.ts +++ b/addons/maintenance/app/service.ts @@ -1,7 +1,7 @@ import { callGatewayAction, type ActionResult } from "../../../lib/gateway-client"; import { fetchPanelInfo, type SanitizedSite } from "../../../lib/snapshot-reader"; import type { SiteContext } from "../../../lib/site-context"; -import type { MaintenanceStatus } from "../action"; +import type { GlobalMaintenanceStatus, MaintenanceStatus } from "../action"; const DOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/; @@ -45,8 +45,8 @@ function action(verb: string, domain: string, input?: string): Promise(verb: string): Promise> { - return callGatewayAction("maintenance", verb, [], undefined, { +function globalAction(verb: string, input?: string): Promise> { + return callGatewayAction("maintenance", verb, [], input, { timeout: 30_000, maxBuffer: 1024 * 1024, }); @@ -111,10 +111,14 @@ export const maintenanceService = { return action(enabled ? "enable" : "disable", domain); }, - async globalStatus(): Promise { - const res = await globalAction<{ global: boolean }>("global-status"); + async globalStatus(): Promise { + const res = await globalAction("global-status"); const data = await requireResult(res, "global maintenance status unavailable"); - return data.global; + return data; + }, + + setGlobalBypasses(ips: string[]): Promise> { + return globalAction("global-set-bypass", JSON.stringify({ ips })); }, async setGlobalEnabled(enabled: boolean): Promise> { diff --git a/addons/maintenance/app/views.client.js b/addons/maintenance/app/views.client.js index 1bf7bea..250f5a3 100644 --- a/addons/maintenance/app/views.client.js +++ b/addons/maintenance/app/views.client.js @@ -370,6 +370,22 @@ async function saveBypasses(domain) { finally { busy(false); } } +async function saveGlobalBypasses() { + const field = CLP_ROOT.getElementById('global-bypass-ips'); + if (!field) return; + const ips = field.value.split(/[\n,]+/).map(function (ip) { return ip.trim(); }).filter(Boolean); + clearNotice(); + busy(true); + try { + const reply = await call('/api/global-bypasses', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ips: ips }) + }); + field.value = reply.data.bypasses.join('\n'); + notify('Global IP bypasses saved.', 'ok'); + } catch (error) { notify('Could not save global IP bypasses: ' + error.message, 'error'); } + finally { busy(false); } +} + function addCurrentIp(ip) { const field = CLP_ROOT.getElementById('bypass-ips'); if (!field || !ip) return; diff --git a/addons/maintenance/app/views.css b/addons/maintenance/app/views.css index 795e483..e76a831 100644 --- a/addons/maintenance/app/views.css +++ b/addons/maintenance/app/views.css @@ -79,6 +79,8 @@ html.dark #template-ace .ace_comment { color:#93a1ad; } .global-card { display:flex; justify-content:space-between; align-items:flex-start; gap:24px; } .global-card h2 { margin:0 0 8px; } .global-card p { margin:0; } +.global-bypass { border-top:1px solid var(--border); margin-top:20px; padding-top:20px; } +.global-bypass .bypass-field textarea { width:100%; } @media (max-width:760px) { .site-heading { display: grid; diff --git a/addons/maintenance/app/views.ts b/addons/maintenance/app/views.ts index db1eb79..30073b6 100644 --- a/addons/maintenance/app/views.ts +++ b/addons/maintenance/app/views.ts @@ -4,6 +4,7 @@ import type { EmbedFragment } from "../../../lib/shadow-embed"; import { mountPath } from "../../../lib/mount"; import { siteTypeLabel, type SiteContext } from "../../../lib/site-context"; import type { MaintenanceSiteView, MaintenanceTemplateView } from "./service"; +import type { GlobalMaintenanceStatus } from "../action"; const BASE = mountPath("maintenance"); @@ -71,8 +72,8 @@ function statusBadge(site: MaintenanceSiteView, globalEnabled = false): string { * bulk edit of the switches below it. It is not one: it decides what visitors * get while every site keeps the setting it has saved. */ -function globalCard(globalEnabled: boolean, disabled: boolean): string { - return `
+function globalCard(globalEnabled: boolean, disabled: boolean, bypasses: string[]): string { + return `

Global maintenance

Serve the maintenance page for every site at once, whatever each site has saved.

@@ -81,10 +82,16 @@ function globalCard(globalEnabled: boolean, disabled: boolean): string { -
`; +
+
+
+

One IPv4 or IPv6 visitor address per line. These skip maintenance on every site, including sites with their own setting on. For Cloudflare sites, enter the visitor IP.

+
`; } -export function fleetView(sites: MaintenanceSiteView[], globalEnabled = false): string { +export function fleetView(sites: MaintenanceSiteView[], globalState: boolean | GlobalMaintenanceStatus = false): string { + const globalEnabled = typeof globalState === "boolean" ? globalState : globalState.global; + const globalBypasses = typeof globalState === "boolean" ? [] : globalState.bypasses; const available = sites.filter((site) => !site.error); const siteMaintenanceCount = available.filter((site) => site.enabled).length; // The override covers the fleet, including the sites this page could not read. @@ -104,7 +111,7 @@ export function fleetView(sites: MaintenanceSiteView[], globalEnabled = false):
In maintenance
${inMaintenanceCount}
Live
${liveCount}
- ${globalCard(globalEnabled, sites.length === 0)} + ${globalCard(globalEnabled, sites.length === 0, globalBypasses)}

Sites

${sites.length ? `${rows}
SiteTypeEffective statusPageBypassesSite setting
` : '
No CloudPanel sites were found.
'}
`; @@ -122,7 +129,7 @@ export function siteView(

Maintenance response

Visitors receive HTTP 503 with a five-minute Retry-After header. ACME certificate challenges and bypassed IPs remain live.

-

IP bypasses

One IPv4 or IPv6 address per line. Requests from these addresses skip maintenance mode.

+

IP bypasses

One IPv4 or IPv6 visitor address per line. For Cloudflare sites, enter the visitor IP. Global bypasses from the overview also apply here.

${currentIp ? `` : ""}
diff --git a/cli/inject.ts b/cli/inject.ts index 3045827..15ba201 100644 --- a/cli/inject.ts +++ b/cli/inject.ts @@ -29,6 +29,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync, statSync, realpathSync } from "node:fs"; +import { dirname, join } from "node:path"; import { NGINX_GLOBAL_SETTINGS, NGINX_MAINTENANCE_STATE_DIR, NGINX_PROXY_STATE_DIR, nginxLayout, TEMPLATE_STATE_DIR, TEMPLATES_DIR, TWIG_CACHE_DIR, @@ -768,7 +769,10 @@ if (-f /var/lib/clp-addons/maintenance/_global/on) { if (-f /var/lib/clp-addons/maintenance/$server_name/on) { set $clp_maintenance 1; } -if (-f /var/lib/clp-addons/maintenance/$server_name/bypass_$remote_addr) { +if (-f /var/lib/clp-addons/maintenance/_global/bypass_$clp_maintenance_ip) { + set $clp_maintenance 0; +} +if (-f /var/lib/clp-addons/maintenance/$server_name/bypass_$clp_maintenance_ip) { set $clp_maintenance 0; } if ($uri ~ ^/\\.well-known/acme-challenge/) { @@ -794,9 +798,46 @@ location = /__clp_addons_maintenance { const NGINX_MAINTENANCE_BLOCK_RE = /\r?\n?[ \t]*# clp-addons:maintenance:start[\s\S]*?[ \t]*# clp-addons:maintenance:end\r?\n?/g; +const MAINTENANCE_GEO_MARKER = "# clp-addons:maintenance-client-ip"; + +/** CloudPanel keeps these ranges for its own Cloudflare-only vhost setting. */ +function cloudflareRanges(path: string): string[] { + let content: string; + try { content = readFileSync(path, "utf8"); } catch { return []; } + return content.split(/\r?\n/).flatMap((line) => { + const match = line.match(/^\s*allow\s+([0-9a-fA-F.:/]+)\s*;/); + return match ? [match[1]!] : []; + }); +} + +function maintenanceGeoContent(ranges: string[]): string { + return `${MAINTENANCE_GEO_MARKER} +# Preserve the connection peer even if CloudPanel's broad real_ip settings +# replaced $remote_addr using a request header. +map $realip_remote_addr $clp_maintenance_peer { + "" $remote_addr; + default $realip_remote_addr; +} +geo $clp_maintenance_peer $clp_cf_peer { + default 0; +${ranges.map((range) => ` ${range} 1;`).join("\n")} +} +map $http_cf_connecting_ip $clp_cf_header_ip { + default ""; + ~^[0-9A-Fa-f:.]+$ $http_cf_connecting_ip; +} +map "$clp_cf_peer:$clp_cf_header_ip" $clp_maintenance_ip { + default $clp_maintenance_peer; + ~^1:.+$ $clp_cf_header_ip; +} +`; +} + export interface MaintenanceNginxPaths { settingsPath?: string; stateDir?: string; + geoPath?: string; + cloudflareIpsPath?: string; } export type MaintenanceNginxState = @@ -841,15 +882,18 @@ function maintenanceBaseline( } } -function maintenancePath(options: MaintenanceNginxPaths): { settingsPath: string; stateDir: string } { +function maintenancePath(options: MaintenanceNginxPaths): { settingsPath: string; stateDir: string; geoPath: string; cloudflareIpsPath: string } { + const settingsPath = options.settingsPath ?? NGINX_GLOBAL_SETTINGS; return { - settingsPath: options.settingsPath ?? NGINX_GLOBAL_SETTINGS, + settingsPath, stateDir: options.stateDir ?? NGINX_MAINTENANCE_STATE_DIR, + geoPath: options.geoPath ?? join(dirname(settingsPath), "sites-enabled", "00-clp-addons-maintenance-client-ip.conf"), + cloudflareIpsPath: options.cloudflareIpsPath ?? join(dirname(settingsPath), "cloudflare", "ips"), }; } export function inspectNginxMaintenance(options: MaintenanceNginxPaths = {}): MaintenanceNginxStatus { - const { settingsPath, stateDir } = maintenancePath(options); + const { settingsPath, stateDir, geoPath, cloudflareIpsPath } = maintenancePath(options); const content = readNginxFile(settingsPath); if (content === null) return { state: "missing", settingsPath, detail: `Nginx global settings do not exist: ${settingsPath}` }; const files = maintenanceNginxFiles(stateDir); @@ -878,6 +922,9 @@ export function inspectNginxMaintenance(options: MaintenanceNginxPaths = {}): Ma if (!content.includes(NGINX_MAINTENANCE_BLOCK)) { return { state: "stale-content", settingsPath, detail: "maintenance block differs from the managed definition" }; } + if (readNginxFile(geoPath) !== maintenanceGeoContent(cloudflareRanges(cloudflareIpsPath))) { + return { state: "stale-content", settingsPath, detail: "maintenance client IP map is missing or outdated" }; + } return { state: "ok", settingsPath }; } @@ -885,7 +932,7 @@ export function reconcileNginxMaintenance( options: MaintenanceNginxPaths & { enabled?: boolean; reload?: boolean } = {}, ): MaintenanceNginxResult { const { enabled = true, reload = true, ...pathOptions } = options; - const { settingsPath: selectedPath, stateDir } = maintenancePath(pathOptions); + const { settingsPath: selectedPath, stateDir, geoPath, cloudflareIpsPath } = maintenancePath(pathOptions); const read = readNginxFile(selectedPath); if (read === null) { return { state: "missing", changed: false, settingsPath: selectedPath, detail: `Nginx global settings do not exist: ${selectedPath}` }; @@ -916,6 +963,10 @@ export function reconcileNginxMaintenance( if (enabled && /(?:location\s+(?:@clp_maintenance|=\s*\/__clp_addons_maintenance)|\$clp_maintenance\b|error_page\s+[^;]*\b418\b)/m.test(upstream)) { return { state: "conflict", changed: false, settingsPath: selectedPath, detail: "an unmanaged maintenance variable or location already exists" }; } + const oldGeo = readNginxFile(geoPath); + if (oldGeo !== null && !oldGeo.startsWith(`${MAINTENANCE_GEO_MARKER}\n`)) { + return { state: "conflict", changed: false, settingsPath: selectedPath, detail: "an unowned maintenance client IP map exists" }; + } if (!baseline && (enabled || read.includes("# clp-addons:maintenance:start"))) { mkdirSync(stateDir, { recursive: true }); writeAtomic(files.pristine, upstream, 0o600); @@ -925,12 +976,30 @@ export function reconcileNginxMaintenance( const rendered = enabled ? `${upstream}\n${NGINX_MAINTENANCE_BLOCK}\n` : upstream; - if (rendered === read) { + const newGeo = enabled ? maintenanceGeoContent(cloudflareRanges(cloudflareIpsPath)) : null; + if (rendered === read && newGeo === oldGeo) { if (!enabled) removeNginxState(files); return { state: enabled ? "ok" : "missing", changed: false, settingsPath: selectedPath }; } const mode = statSync(settingsPath).mode & 0o777; - writeAtomic(settingsPath, rendered, mode); + const restoreGeo = () => { + if (newGeo === oldGeo) return; + if (oldGeo === null) rmSync(geoPath, { force: true }); + else writeAtomic(geoPath, oldGeo, 0o644); + }; + try { + if (newGeo !== oldGeo) { + if (newGeo === null) rmSync(geoPath, { force: true }); + else { + mkdirSync(dirname(geoPath), { recursive: true }); + writeAtomic(geoPath, newGeo, 0o644); + } + } + if (rendered !== read) writeAtomic(settingsPath, rendered, mode); + } catch (error) { + restoreGeo(); + throw error; + } if (!reload) { if (!enabled) removeNginxState(files); return { state: enabled ? "ok" : "missing", changed: true, settingsPath: selectedPath }; @@ -938,11 +1007,13 @@ export function reconcileNginxMaintenance( const tested = commandFailure("nginx", ["-t"]); if (tested) { restoreNginxContent(settingsPath, read); + restoreGeo(); return { state: "validation-failed", changed: false, settingsPath: selectedPath, detail: `nginx -t failed: ${tested}` }; } const reloaded = commandFailure("systemctl", ["reload", "nginx"]); if (reloaded) { restoreNginxContent(settingsPath, read); + restoreGeo(); return { state: "validation-failed", changed: false, settingsPath: selectedPath, detail: `nginx reload failed: ${reloaded}` }; } if (!enabled) removeNginxState(files); diff --git a/docs/decisions/maintenance.md b/docs/decisions/maintenance.md index c274aaf..7dcc8bf 100644 --- a/docs/decisions/maintenance.md +++ b/docs/decisions/maintenance.md @@ -17,7 +17,10 @@ how many have maintenance saved off. While it is on, every site serves 503 and a site whose own setting is off, or could not be read, shows a "Maintenance (Global)" badge and is counted as in maintenance; turning it off returns each site to its saved setting. Turning it on or off purges Varnish -cache across the fleet. +cache across the fleet. The card also saves up to 64 global IP bypasses. Each +one applies to every site, including sites with their own maintenance setting +on, and remains saved when the global override is off. Site bypasses continue +to apply to their own site. A domain query opens the focused editor for that site. That page is drawn in the shell's site mode, so CloudPanel's site information and site tabs stay above it @@ -46,11 +49,23 @@ The addon installs one marked block in `/etc/nginx/global_settings`, which all customer site templates include. Nginx checks `/var/lib/clp-addons/maintenance/_global/on` and `/var/lib/clp-addons/maintenance/$server_name/on` on every request and clears -the maintenance decision when a matching `bypass_$remote_addr` file exists. +the maintenance decision when a matching `bypass_$clp_maintenance_ip` file +exists in either the global or site's directory. CloudPanel writes the database domain first in `server_name`, so aliases share the canonical site's state and template. Turning a site on or off is an atomic file operation and needs no Nginx reload. +A managed HTTP-level map in `/etc/nginx/sites-enabled/00-clp-addons-maintenance-client-ip.conf` +uses CloudPanel's `/etc/nginx/cloudflare/ips` ranges to recognize the actual +connection peer. Only a request from one of those peers can use +`CF-Connecting-IP` as its bypass address. Other requests use the connection +peer, even if CloudPanel's broad real-IP setting changed `$remote_addr` from a +client-supplied header. This map leaves CloudPanel's `$remote_addr` and +Cloudflare-only access rules untouched. Reconciliation updates the map when +CloudPanel's Cloudflare range file changes, after validating and reloading +Nginx. If CloudPanel has no range file, the map trusts no proxy headers and +direct connections still use their peer address. + The check returns an internal 418 sentinel and maps only that sentinel to the public 503 maintenance response. An application's own 503 response therefore keeps its own error handling. `/.well-known/acme-challenge/` is exempted before @@ -70,20 +85,21 @@ without assuming it was scheduled or promising a recovery time. ## State and privileges Only the root gateway changes maintenance state. It accepts a fixed verb set, -normalizes domains and IP addresses, checks that the domain is a current -CloudPanel site, and bounds a custom template at 256 KiB. Bypass updates replace -the complete list and allow at most 64 addresses. - -The state root and per-domain directories are mode `0711`, which lets Nginx -traverse a known path without listing domains or bypasses. Toggle and bypass -files are mode `0600`; public HTML files are mode `0644`. Disabling or -uninstalling the addon removes the global Nginx block and keeps site state +normalizes domains and IP addresses, checks that site-scoped domains are current +CloudPanel sites, and bounds a custom template at 256 KiB. Each bypass update +replaces the complete list for its scope and allows at most 64 addresses. + +The state root, global directory, and per-domain directories are mode `0711`, +which lets Nginx traverse a known path without listing domains or bypasses. +Per-site toggle and all bypass files are mode `0600`; the global toggle and +public HTML files are mode `0644`. Disabling or +uninstalling the addon removes the global Nginx block and client-IP map, and keeps site state unless purge was requested. ## Reconciliation The global-settings reconciler records the pristine file and its SHA-256 hash. It renders only when the current unmarked content still matches that baseline, -runs `nginx -t`, reloads the distro Nginx service, and restores the prior file -on validation or reload failure. Repair and the path watcher reconcile this -block alongside Twig and the CloudPanel manager proxy. +runs `nginx -t`, reloads the distro Nginx service, and restores the prior +settings and client-IP map on validation or reload failure. Repair and the path +watcher reconcile this block alongside Twig and the CloudPanel manager proxy. diff --git a/lib/gateway-protocol.ts b/lib/gateway-protocol.ts index ee3bb13..2149e86 100644 --- a/lib/gateway-protocol.ts +++ b/lib/gateway-protocol.ts @@ -66,6 +66,7 @@ export const MAINTENANCE_ALLOWED_VERBS = new Set([ "global-status", "global-enable", "global-disable", + "global-set-bypass", ]); /** diff --git a/tests/test-maintenance.test.ts b/tests/test-maintenance.test.ts index 2cbe13f..dcd6422 100644 --- a/tests/test-maintenance.test.ts +++ b/tests/test-maintenance.test.ts @@ -44,22 +44,28 @@ const actionOptions = >(paths: MaintenanceActi test("global maintenance actions enable, report status, and disable fleet-wide maintenance mode", async () => { const { root, paths } = fixture(); try { - const initial = await executeMaintenanceAction(["global-status"], actionOptions(paths)) as { global: boolean }; - expect(initial).toEqual({ global: false }); + const initial = await executeMaintenanceAction(["global-status"], actionOptions(paths)) as { global: boolean; bypasses: string[] }; + expect(initial).toEqual({ global: false, bypasses: [] }); + + const bypassed = await executeMaintenanceAction(["global-set-bypass"], actionOptions(paths, { + input: JSON.stringify({ ips: ["203.0.113.8", "2001:db8::1"] }), + })) as { global: boolean; bypasses: string[] }; + expect(bypassed).toEqual({ global: false, bypasses: ["2001:db8::1", "203.0.113.8"] }); + expect(existsSync(join(paths.dataDir, "_global", "bypass_203.0.113.8"))).toBe(true); const enabled = await executeMaintenanceAction(["global-enable"], actionOptions(paths)) as { ok: boolean; global: boolean }; expect(enabled).toEqual({ ok: true, global: true }); expect(lstatSync(join(paths.dataDir, "_global", "on")).isFile()).toBe(true); - const statusAfterEnable = await executeMaintenanceAction(["global-status"], actionOptions(paths)) as { global: boolean }; - expect(statusAfterEnable).toEqual({ global: true }); + const statusAfterEnable = await executeMaintenanceAction(["global-status"], actionOptions(paths)) as { global: boolean; bypasses: string[] }; + expect(statusAfterEnable).toEqual({ global: true, bypasses: ["2001:db8::1", "203.0.113.8"] }); const disabled = await executeMaintenanceAction(["global-disable"], actionOptions(paths)) as { ok: boolean; global: boolean }; expect(disabled).toEqual({ ok: true, global: false }); expect(existsSync(join(paths.dataDir, "_global", "on"))).toBe(false); - const finalStatus = await executeMaintenanceAction(["global-status"], actionOptions(paths)) as { global: boolean }; - expect(finalStatus).toEqual({ global: false }); + const finalStatus = await executeMaintenanceAction(["global-status"], actionOptions(paths)) as { global: boolean; bypasses: string[] }; + expect(finalStatus).toEqual({ global: false, bypasses: ["2001:db8::1", "203.0.113.8"] }); } finally { rmSync(root, { recursive: true, force: true }); } @@ -139,24 +145,39 @@ test("global-settings reconciliation is idempotent, drift-gated, and reversible" const root = mkdtempSync(join(tmpdir(), "clp-maintenance-nginx-")); const settingsPath = join(root, "global_settings"); const stateDir = join(root, "state"); + const geoPath = join(root, "sites-enabled", "00-clp-addons-maintenance-client-ip.conf"); + const cloudflareIpsPath = join(root, "cloudflare", "ips"); const original = "client_max_body_size 128m;"; writeFileSync(settingsPath, original); + mkdirSync(join(root, "cloudflare")); + writeFileSync(cloudflareIpsPath, "allow 173.245.48.0/20;\nallow 2400:cb00::/32;\ndeny all;\n"); try { - const installed = reconcileNginxMaintenance({ settingsPath, stateDir, reload: false }); + const installed = reconcileNginxMaintenance({ settingsPath, stateDir, geoPath, cloudflareIpsPath, reload: false }); expect(installed).toMatchObject({ state: "ok", changed: true }); expect(readFileSync(settingsPath, "utf8")).toContain(NGINX_MAINTENANCE_BLOCK); - expect(inspectNginxMaintenance({ settingsPath, stateDir }).state).toBe("ok"); - - expect(reconcileNginxMaintenance({ settingsPath, stateDir, reload: false }).changed).toBe(false); + expect(inspectNginxMaintenance({ settingsPath, stateDir, geoPath, cloudflareIpsPath }).state).toBe("ok"); + const geo = readFileSync(geoPath, "utf8"); + expect(geo).toContain("173.245.48.0/20 1;"); + expect(geo).toContain("2400:cb00::/32 1;"); + expect(geo).toContain("map $realip_remote_addr $clp_maintenance_peer"); + expect(geo).toContain("default $clp_maintenance_peer;"); + expect(geo).toContain("~^1:.+$ $clp_cf_header_ip;"); + writeFileSync(cloudflareIpsPath, "allow 173.245.48.0/20;\nallow 2400:cb00::/32;\nallow 104.16.0.0/13;\ndeny all;\n"); + expect(inspectNginxMaintenance({ settingsPath, stateDir, geoPath, cloudflareIpsPath }).state).toBe("stale-content"); + expect(reconcileNginxMaintenance({ settingsPath, stateDir, geoPath, cloudflareIpsPath, reload: false }).changed).toBe(true); + expect(readFileSync(geoPath, "utf8")).toContain("104.16.0.0/13 1;"); + + expect(reconcileNginxMaintenance({ settingsPath, stateDir, geoPath, cloudflareIpsPath, reload: false }).changed).toBe(false); writeFileSync(settingsPath, readFileSync(settingsPath, "utf8").replace(original, `${original}\nserver_tokens off;`)); - const drift = reconcileNginxMaintenance({ settingsPath, stateDir, reload: false }); + const drift = reconcileNginxMaintenance({ settingsPath, stateDir, geoPath, cloudflareIpsPath, reload: false }); expect(drift.state).toBe("upstream-changed"); expect(drift.changed).toBe(false); writeFileSync(settingsPath, `${original}\n${NGINX_MAINTENANCE_BLOCK}\n`); - const removed = reconcileNginxMaintenance({ settingsPath, stateDir, enabled: false, reload: false }); + const removed = reconcileNginxMaintenance({ settingsPath, stateDir, geoPath, cloudflareIpsPath, enabled: false, reload: false }); expect(removed).toMatchObject({ state: "missing", changed: true }); expect(readFileSync(settingsPath, "utf8")).toBe(original); + expect(existsSync(geoPath)).toBe(false); expect(existsSync(stateDir) ? Bun.file(join(stateDir, "global-settings.sha256")).size : 0).toBe(0); } finally { rmSync(root, { recursive: true, force: true }); @@ -430,6 +451,35 @@ test("global toggle API guards mutations and toggles fleet-wide maintenance mode } }); +test("global bypass API validates and saves addresses", async () => { + const token = "global-bypass-test"; + const request = (body: unknown, csrf = true) => new Request("https://panel.example.test:8443/addons/maintenance/api/global-bypasses", { + method: "PUT", + body: JSON.stringify(body), + headers: { + "content-type": "application/json", + host: "panel.example.test:8443", + origin: "https://panel.example.test:8443", + ...(csrf ? { cookie: `clp_addons_csrf=${token}`, "x-clp-addons-csrf": token } : {}), + }, + }); + expect((await handleMaintenance(request({ ips: [] }, false), "/api/global-bypasses")).status).toBe(403); + expect((await handleMaintenance(request({ ips: [42] }), "/api/global-bypasses")).status).toBe(400); + const original = maintenanceService.setGlobalBypasses; + try { + let received: string[] = []; + maintenanceService.setGlobalBypasses = async (ips) => { + received = ips; + return { ok: true, data: { global: true, bypasses: ips } }; + }; + const response = await handleMaintenance(request({ ips: ["203.0.113.8"] }), "/api/global-bypasses"); + expect(response.status).toBe(200); + expect(received).toEqual(["203.0.113.8"]); + } finally { + maintenanceService.setGlobalBypasses = original; + } +}); + test("bulk toggle API guards mutations and toggles all available sites", async () => { const token = "bulk-test-csrf-token"; const request = (path: string, body: unknown, headers?: Record) => new Request(`https://panel.example.test:8443/addons/maintenance${path}`, { @@ -563,6 +613,9 @@ test("maintenance integration preserves ACME and normalizes non-GET errors throu expect(NGINX_MAINTENANCE_BLOCK).toContain("$uri = /__clp_addons_maintenance"); expect(NGINX_MAINTENANCE_BLOCK).toContain("maintenance/_global/on"); expect(NGINX_MAINTENANCE_BLOCK).toContain("maintenance/$server_name/on"); + expect(NGINX_MAINTENANCE_BLOCK).toContain("maintenance/_global/bypass_$clp_maintenance_ip"); + expect(NGINX_MAINTENANCE_BLOCK).toContain("maintenance/$server_name/bypass_$clp_maintenance_ip"); + expect(NGINX_MAINTENANCE_BLOCK).not.toContain("bypass_$remote_addr"); expect(NGINX_MAINTENANCE_BLOCK).not.toContain("maintenance/$host/on"); expect(NGINX_MAINTENANCE_BLOCK).toContain("return 418;"); expect(NGINX_MAINTENANCE_BLOCK).toContain("error_page 418 =503 /__clp_addons_maintenance;"); @@ -578,6 +631,7 @@ test("maintenance integration preserves ACME and normalizes non-GET errors throu expect(MAINTENANCE_ALLOWED_VERBS).toEqual(new Set([ "status", "enable", "disable", "get-template", "set-template", "reset-template", "set-bypass", "global-status", "global-enable", "global-disable", + "global-set-bypass", ])); }); diff --git a/tests/test-shadow-embed.test.ts b/tests/test-shadow-embed.test.ts index 6c3f436..615f034 100644 --- a/tests/test-shadow-embed.test.ts +++ b/tests/test-shadow-embed.test.ts @@ -116,7 +116,7 @@ test("the fragment route answers with the page and the CSRF cookie its actions e template: maintenanceService.template, }; try { - maintenanceService.globalStatus = async () => false; + maintenanceService.globalStatus = async () => ({ global: false, bypasses: [] }); maintenanceService.site = async (domain: string) => ({ site: { domain, type: "php", user: "shop", enabled: false, customTemplate: false, bypasses: [] }, context: { domain, user: "shop", type: "php" }, diff --git a/tools/preview-ui.ts b/tools/preview-ui.ts index 70db1ff..5a73181 100644 --- a/tools/preview-ui.ts +++ b/tools/preview-ui.ts @@ -688,7 +688,7 @@ const server = Bun.serve({ site ? `Maintenance — ${site.domain}` : "Maintenance Mode", site ? maintenanceSiteView(site, { domain: site.domain, custom: site.customTemplate, html: DEFAULT_MAINTENANCE_TEMPLATE }, "203.0.113.8", globalEnabled) - : maintenanceFleetView(empty ? [] : maintenanceSites, globalEnabled), + : maintenanceFleetView(empty ? [] : maintenanceSites, { global: globalEnabled, bypasses: empty ? [] : ["203.0.113.8"] }), notice, site ? { From f9a5bf8c9f7fe99517e635998e11dca8a512c0c4 Mon Sep 17 00:00:00 2001 From: 7heMech <83923848+7heMech@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:51:58 +0000 Subject: [PATCH 2/2] Watch Cloudflare range changes for maintenance reconciliation --- cli/paths.ts | 1 + cli/provision.ts | 18 +++++++++--------- docs/decisions/maintenance.md | 6 ++++-- tests/test-provision.test.ts | 7 +++++-- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/cli/paths.ts b/cli/paths.ts index ecdd39b..5c73e32 100644 --- a/cli/paths.ts +++ b/cli/paths.ts @@ -63,6 +63,7 @@ export function nginxLayout(panelDir = PANEL_NGINX_DIR): NginxLayout { } export const NGINX_PROXY_STATE_DIR = "/var/lib/clp-addons/nginx"; export const NGINX_GLOBAL_SETTINGS = "/etc/nginx/global_settings"; +export const CLOUDFLARE_IPS_PATH = "/etc/nginx/cloudflare/ips"; export const NGINX_MAINTENANCE_STATE_DIR = "/var/lib/clp-addons/nginx-maintenance"; const PANEL_APP = "/home/clp/htdocs/app/files"; diff --git a/cli/provision.ts b/cli/provision.ts index 7cc69a3..7a6d331 100644 --- a/cli/provision.ts +++ b/cli/provision.ts @@ -2,14 +2,14 @@ import { chmodSync, chownSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { ANCHOR_SERVICE, CLI_BIN, CLOUDFLARE_RECONCILE_SERVICE, CLOUDFLARE_RECONCILE_TIMER, CONFIG_DIR, LIBEXEC_DIR, LEGACY_UNITS, INSTATIC_BACKUP_CRON, AUTH_SERVICE_UNIT, AUTH_SOCKET_PATH, AUTH_SOCKET_UNIT, LEGACY_USERS, LOCK_DIR, MANAGER_UNIT, PANEL_GROUP, PANEL_USER, RECONCILE_PATH, RECONCILE_SERVICE, RECONCILE_TIMER, SERVICE_GROUP, SERVICE_USER, SESSION_DIR, SHARED_GROUP, SOCKET_DIR, STATE_DIR, - SYSTEMD_DIR, TWIG_CACHE_DIR, PANEL_IDENTITY_PATH, NGINX_GLOBAL_SETTINGS, + SYSTEMD_DIR, TWIG_CACHE_DIR, PANEL_IDENTITY_PATH, NGINX_GLOBAL_SETTINGS, CLOUDFLARE_IPS_PATH, } from "./paths"; import { ADDONS, ADDON_NAMES, templateWatchPaths, type AddonSpec } from "./addon-catalog"; import { findMasterVhost, panelVhostWatchPath } from "./inject"; @@ -689,7 +689,7 @@ RandomizedDelaySec=30 WantedBy=timers.target `, path: `[Unit] -Description=CloudPanel Addons template watcher +Description=CloudPanel Addons configuration watcher [Path] ${reconcileWatchPaths().map((path) => `PathChanged=${path}`).join("\n")} @@ -735,15 +735,15 @@ WantedBy=timers.target } /** - * Everything the watcher reconciles: the addons' Twig anchors and the panel - * vhost carrying the /addons/ proxy. The vhost belongs here because on the - * CloudPanel layout it is owned by the panel user, so a panel action can - * rewrite it at any time; the reconciler puts the block back, but only once - * something tells it to look. + * Everything the watcher's fast repair reconciles: Twig anchors, the panel + * vhost, global Nginx settings, and CloudPanel's Cloudflare range list. Watch + * both the range file for in-place writes and its directory for atomic + * replacement; either change requires regenerating the maintenance IP map. */ function reconcileWatchPaths(): string[] { const vhost = panelVhostWatchPath(); - return [...templateWatchPaths(), ...(vhost ? [vhost] : []), NGINX_GLOBAL_SETTINGS]; + return [...templateWatchPaths(), ...(vhost ? [vhost] : []), NGINX_GLOBAL_SETTINGS, + CLOUDFLARE_IPS_PATH, dirname(CLOUDFLARE_IPS_PATH)]; } /** diff --git a/docs/decisions/maintenance.md b/docs/decisions/maintenance.md index 7dcc8bf..fa3b08a 100644 --- a/docs/decisions/maintenance.md +++ b/docs/decisions/maintenance.md @@ -63,8 +63,10 @@ peer, even if CloudPanel's broad real-IP setting changed `$remote_addr` from a client-supplied header. This map leaves CloudPanel's `$remote_addr` and Cloudflare-only access rules untouched. Reconciliation updates the map when CloudPanel's Cloudflare range file changes, after validating and reloading -Nginx. If CloudPanel has no range file, the map trusts no proxy headers and -direct connections still use their peer address. +Nginx. The path watcher monitors the range file and its directory so both +in-place writes and atomic replacements trigger reconciliation; the periodic +repair timer is a fallback. If CloudPanel has no range file, the map trusts no +proxy headers and direct connections still use their peer address. The check returns an internal 418 sentinel and maps only that sentinel to the public 503 maintenance response. An application's own 503 response therefore diff --git a/tests/test-provision.test.ts b/tests/test-provision.test.ts index 301859b..f8ab361 100644 --- a/tests/test-provision.test.ts +++ b/tests/test-provision.test.ts @@ -604,20 +604,23 @@ test("the panel Nginx instance is detected from its tree, not a version string", } }); -test("the path unit watches the panel vhost as well as the addon templates", () => { +test("the path unit watches panel files and Cloudflare range replacements", () => { const unit = reconcileUnits().path; const watched = unit.split("\n").filter((line) => line.startsWith("PathChanged=")).map((line) => line.slice(12)); expect(watched.some((path) => path.endsWith(".html.twig"))).toBe(true); // The proxy block lives in a panel-owned file, so a panel action can remove // it; the watcher is what makes the reconciler put it back promptly. expect(watched.some((path) => path.endsWith("/cloudpanel.conf"))).toBe(true); + expect(watched).toContain("/etc/nginx/cloudflare/ips"); + expect(watched).toContain("/etc/nginx/cloudflare"); }); -test("the watcher's fast path reconciles the proxy, not just the anchors", () => { +test("the watcher's fast path reconciles maintenance and the proxy", () => { const source = readFileSync(join(import.meta.dir, "..", "cli/repair.ts"), "utf8"); const branchStart = source.indexOf('flags["anchors-only"] === true'); const branch = source.slice(branchStart, source.indexOf("return;", branchStart)); expect(branch.includes("reconcileAnchors(quiet)")).toBe(true); + expect(branch.includes("reconcileMaintenanceNginx(quiet)")).toBe(true); expect(branch.includes("reconcileNginx(quiet)")).toBe(true); });