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/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 c274aaf..fa3b08a 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,25 @@ 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. 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 keeps its own error handling. `/.well-known/acme-challenge/` is exempted before @@ -70,20 +87,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-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); }); 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 ? {