Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 58 additions & 41 deletions addons/maintenance/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export interface MaintenanceStatus {
bypasses: string[];
}

export interface GlobalMaintenanceStatus {
global: boolean;
bypasses: string[];
}

export interface MaintenanceActionPaths {
dataDir: string;
lockDir: string;
Expand Down Expand Up @@ -62,7 +67,8 @@ type MaintenanceVerb =
| "set-bypass"
| "global-status"
| "global-enable"
| "global-disable";
| "global-disable"
| "global-set-bypass";

interface ParsedAction {
verb: MaintenanceVerb;
Expand All @@ -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 <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 <domain> | {global-status|global-enable|global-disable|global-set-bypass}");
}
let domain = "";
for (let i = 1; i < argv.length; i++) {
Expand Down Expand Up @@ -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<string[]> {
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);
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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(
Expand Down
26 changes: 21 additions & 5 deletions addons/maintenance/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 : "";
}

Expand Down Expand Up @@ -71,16 +74,16 @@ 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", '<div class="alert">That is not a valid hostname.</div>', updateNotice), csrf, 400);
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 html(
layout(
`Maintenance — ${domain}`,
siteView(page.site, template.data, clientIp(req), globalEnabled),
siteView(page.site, template.data, clientIp(req), globalStatus.global),
updateNotice,
page.context,
),
Expand All @@ -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(
Expand All @@ -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<string, unknown>;
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;
Expand Down
16 changes: 10 additions & 6 deletions addons/maintenance/app/service.ts
Original file line number Diff line number Diff line change
@@ -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])?)+$/;

Expand Down Expand Up @@ -45,8 +45,8 @@ function action<T>(verb: string, domain: string, input?: string): Promise<Action
});
}

function globalAction<T>(verb: string): Promise<ActionResult<T>> {
return callGatewayAction<T>("maintenance", verb, [], undefined, {
function globalAction<T>(verb: string, input?: string): Promise<ActionResult<T>> {
return callGatewayAction<T>("maintenance", verb, [], input, {
timeout: 30_000,
maxBuffer: 1024 * 1024,
});
Expand Down Expand Up @@ -111,10 +111,14 @@ export const maintenanceService = {
return action(enabled ? "enable" : "disable", domain);
},

async globalStatus(): Promise<boolean> {
const res = await globalAction<{ global: boolean }>("global-status");
async globalStatus(): Promise<GlobalMaintenanceStatus> {
const res = await globalAction<GlobalMaintenanceStatus>("global-status");
const data = await requireResult(res, "global maintenance status unavailable");
return data.global;
return data;
},

setGlobalBypasses(ips: string[]): Promise<ActionResult<GlobalMaintenanceStatus>> {
return globalAction("global-set-bypass", JSON.stringify({ ips }));
},

async setGlobalEnabled(enabled: boolean): Promise<ActionResult<{ global: boolean }>> {
Expand Down
16 changes: 16 additions & 0 deletions addons/maintenance/app/views.client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions addons/maintenance/app/views.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 13 additions & 6 deletions addons/maintenance/app/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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 `<div class="card global-card" data-global-maintenance="${globalEnabled}">
function globalCard(globalEnabled: boolean, disabled: boolean, bypasses: string[]): string {
return `<div class="card" data-global-maintenance="${globalEnabled}"><div class="global-card">
<div>
<h2>Global maintenance</h2>
<p>Serve the maintenance page for every site at once, whatever each site has saved.</p>
Expand All @@ -81,10 +82,16 @@ function globalCard(globalEnabled: boolean, disabled: boolean): string {
<label class="switch-field" for="global-toggle"><span class="switch-state" id="global-state">${globalEnabled ? "On" : "Off"}</span>
<span class="switch switch-danger"><input type="checkbox" id="global-toggle" ${globalEnabled ? "checked" : ""} ${disabled ? "disabled" : ""} onchange="toggleGlobalMaintenance(this.checked)"><span></span></span>
</label>
</div>`;
</div>
<div class="global-bypass"><label class="bypass-field" for="global-bypass-ips"><span>Global IP bypasses</span><textarea id="global-bypass-ips" spellcheck="false">${esc(bypasses.join("\n"))}</textarea></label>
<div class="actions bypass-actions"><button class="btn btn-primary" type="button" onclick="saveGlobalBypasses()">Save bypasses</button></div>
<p class="hint">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.</p>
</div></div>`;
}

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.
Expand All @@ -104,7 +111,7 @@ export function fleetView(sites: MaintenanceSiteView[], globalEnabled = false):
<div class="stat"><div class="label">In maintenance</div><div class="value">${inMaintenanceCount}</div></div>
<div class="stat"><div class="label">Live</div><div class="value">${liveCount}</div></div>
</div>
${globalCard(globalEnabled, sites.length === 0)}
${globalCard(globalEnabled, sites.length === 0, globalBypasses)}
<div class="card card-table"><div class="card-header"><h2>Sites</h2></div>
${sites.length ? `<table class="fleet-table maintenance-fleet-table"><thead><tr><th scope="col">Site</th><th scope="col">Type</th><th scope="col">Effective status</th><th scope="col">Page</th><th scope="col">Bypasses</th><th scope="col" class="action-cell">Site setting</th></tr></thead><tbody data-global-maintenance="${globalEnabled}">${rows}</tbody></table>` : '<div class="empty">No CloudPanel sites were found.</div>'}
</div>`;
Expand All @@ -122,7 +129,7 @@ export function siteView(
<div class="card"><div class="switch-row"><div><h2>Maintenance response</h2><p class="hint">Visitors receive HTTP 503 with a five-minute Retry-After header. ACME certificate challenges and bypassed IPs remain live.</p></div>
<label class="switch switch-danger"><input type="checkbox" data-toggle-domain="${esc(site.domain)}" data-available="true" aria-label="Maintenance mode for ${esc(site.domain)}" ${site.enabled ? "checked" : ""} onchange="toggleMaintenance('${escJs(site.domain)}', this.checked)"><span></span></label>
</div></div>
<div class="card"><div class="card-header"><div><h2>IP bypasses</h2><p class="hint">One IPv4 or IPv6 address per line. Requests from these addresses skip maintenance mode.</p></div></div>
<div class="card"><div class="card-header"><div><h2>IP bypasses</h2><p class="hint">One IPv4 or IPv6 visitor address per line. For Cloudflare sites, enter the visitor IP. Global bypasses from the overview also apply here.</p></div></div>
<div class="bypass-grid"><label class="bypass-field" for="bypass-ips"><span>Allowed IP addresses</span><textarea id="bypass-ips" spellcheck="false">${esc(site.bypasses.join("\n"))}</textarea></label>
<div class="actions bypass-actions">${currentIp ? `<button class="btn" type="button" onclick="addCurrentIp('${escJs(currentIp)}')">Add my IP (${esc(currentIp)})</button>` : ""}<button class="btn btn-primary" type="button" onclick="saveBypasses('${escJs(site.domain)}')">Save bypasses</button></div></div>
</div>
Expand Down
Loading