Replace unsafe legacy Fileman symlink activation with an authenticated, root-owned account runner and crash-safe pointer reconciliation.
515 lines
19 KiB
JavaScript
515 lines
19 KiB
JavaScript
import crypto from "node:crypto";
|
|
|
|
import { DeploymentError, deriveCpanelRoot, normalizeRemoteRoot } from "./cpanel-deploy-lib.mjs";
|
|
|
|
const SAFE_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
|
|
function succeeded(value) {
|
|
return value === 1 || value === "1" || value === true;
|
|
}
|
|
|
|
function required(env, name) {
|
|
const value = env[name];
|
|
const hasControlCharacter =
|
|
typeof value === "string" &&
|
|
Array.from(value).some((character) => {
|
|
const code = character.charCodeAt(0);
|
|
return code <= 31 || code === 127;
|
|
});
|
|
if (typeof value !== "string" || !value || hasControlCharacter) {
|
|
throw new DeploymentError(`Missing or invalid ${name}.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function httpsUrl(value, name) {
|
|
let url;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
throw new DeploymentError(`${name} must be a valid HTTPS URL.`);
|
|
}
|
|
if (url.protocol !== "https:" || url.username || url.password) {
|
|
throw new DeploymentError(`${name} must be a credential-free HTTPS URL.`);
|
|
}
|
|
return url.href.endsWith("/") ? url.href : `${url.href}/`;
|
|
}
|
|
|
|
export function readRootConfig(env = process.env) {
|
|
const user = required(env, "PRODUCTION_CPANEL_USER");
|
|
if (!SAFE_COMPONENT.test(user)) {
|
|
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
|
|
}
|
|
const webroot = normalizeRemoteRoot(env.PRODUCTION_CPANEL_WEBROOT || "public_html", "PRODUCTION_CPANEL_WEBROOT");
|
|
if (webroot.includes("/")) {
|
|
throw new DeploymentError("PRODUCTION_CPANEL_WEBROOT must be one account-home entry.");
|
|
}
|
|
return {
|
|
user,
|
|
token: required(env, "PRODUCTION_CPANEL_API_TOKEN"),
|
|
apiUrl: httpsUrl(required(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"),
|
|
deploymentRoot: deriveCpanelRoot(required(env, "PRODUCTION_CPANEL_PATH"), user),
|
|
webroot,
|
|
frontendUrl: httpsUrl(env.PRODUCTION_FRONTEND_URL || "https://truckwash.io", "PRODUCTION_FRONTEND_URL"),
|
|
};
|
|
}
|
|
|
|
function entryName(entry) {
|
|
return String(entry?.file || entry?.name || entry?.basename || "");
|
|
}
|
|
|
|
function normalizedType(entry) {
|
|
const value = String(entry?.type || entry?.filetype || "").toLowerCase();
|
|
if (value.includes("link") || entry?.islink) return "link";
|
|
if (value.includes("dir") || entry?.isdir) return "dir";
|
|
if (value.includes("file") || entry?.isfile) return "file";
|
|
return value || "unknown";
|
|
}
|
|
|
|
function normalizeAccountPath(config, value) {
|
|
const normalized = String(value || "")
|
|
.replaceAll("\\", "/")
|
|
.replace(/^\/+|\/+$/g, "");
|
|
const homePrefix = `home/${config.user}/`;
|
|
return normalized.startsWith(homePrefix) ? normalized.slice(homePrefix.length) : normalized;
|
|
}
|
|
|
|
function safeAccountPath(value, label = "cPanel path") {
|
|
const normalized = String(value || "").replace(/^\/+/, "");
|
|
if (!normalized || /[\\,]/.test(normalized) || normalized.split("/").some((part) => !SAFE_COMPONENT.test(part))) {
|
|
throw new DeploymentError(`${label} is unsafe.`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function uapiResult(payload) {
|
|
return payload?.result && typeof payload.result === "object" ? payload.result : payload;
|
|
}
|
|
|
|
function responseShape(payload) {
|
|
const result = uapiResult(payload);
|
|
const rootKeys = payload && typeof payload === "object" ? Object.keys(payload).sort().join(",") : typeof payload;
|
|
const resultKeys = result && typeof result === "object" ? Object.keys(result).sort().join(",") : typeof result;
|
|
const dataType = Array.isArray(result?.data) ? "array" : typeof result?.data;
|
|
return `response shape root=[${rootKeys}] result=[${resultKeys}] statusType=${typeof result?.status} dataType=${dataType}`;
|
|
}
|
|
|
|
function responseError(payload) {
|
|
return (
|
|
payload?.cpanelresult?.error ||
|
|
payload?.cpanelresult?.event?.reason ||
|
|
payload?.cpanelresult?.data?.find?.((item) => item?.reason || item?.error)?.reason ||
|
|
payload?.cpanelresult?.data?.find?.((item) => item?.reason || item?.error)?.error ||
|
|
payload?.result?.errors?.[0] ||
|
|
payload?.result?.messages?.[0] ||
|
|
payload?.errors?.[0] ||
|
|
payload?.messages?.[0] ||
|
|
payload?.error ||
|
|
payload?.message ||
|
|
"unknown cPanel error"
|
|
);
|
|
}
|
|
|
|
export class CpanelAccountClient {
|
|
constructor(config, options = {}) {
|
|
this.config = config;
|
|
this.fetch = options.fetchImpl || globalThis.fetch;
|
|
this.timeoutMs = options.timeoutMs || 30_000;
|
|
this.allowedMutable = new Set(options.allowedMutable || []);
|
|
}
|
|
|
|
redact(value) {
|
|
let result = String(value || "");
|
|
for (const secret of [this.config.user, this.config.token, this.config.apiUrl].filter(Boolean)) {
|
|
result = result.replaceAll(secret, "[redacted]");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async request(url) {
|
|
let response;
|
|
try {
|
|
response = await this.fetch(url, {
|
|
headers: {
|
|
Accept: "application/json",
|
|
Authorization: `cpanel ${this.config.user}:${this.config.token}`,
|
|
},
|
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
});
|
|
} catch (error) {
|
|
throw new DeploymentError("cPanel request failed.", { cause: error });
|
|
}
|
|
if (!response.ok) throw new DeploymentError(`cPanel returned HTTP ${response.status}.`);
|
|
try {
|
|
return await response.json();
|
|
} catch (error) {
|
|
throw new DeploymentError("cPanel returned invalid JSON.", { cause: error });
|
|
}
|
|
}
|
|
|
|
async api2(functionName, parameters) {
|
|
const url = new URL("json-api/cpanel", this.config.apiUrl);
|
|
url.searchParams.set("cpanel_jsonapi_user", this.config.user);
|
|
url.searchParams.set("cpanel_jsonapi_apiversion", "2");
|
|
url.searchParams.set("cpanel_jsonapi_module", "Fileman");
|
|
url.searchParams.set("cpanel_jsonapi_func", functionName);
|
|
for (const [name, value] of Object.entries(parameters)) url.searchParams.set(name, String(value));
|
|
const payload = await this.request(url);
|
|
const result = payload?.cpanelresult;
|
|
const failed = result?.data?.find?.((item) => item?.result === 0 || item?.result === false);
|
|
if (!succeeded(result?.event?.result) || failed) {
|
|
throw new DeploymentError(`cPanel Fileman ${functionName} failed: ${this.redact(responseError(payload))}`);
|
|
}
|
|
return [...(result?.data || []), ...(result?.files || []), ...(result?.dirs || [])];
|
|
}
|
|
|
|
async list(directory) {
|
|
const dir = directory === "." ? "." : safeAccountPath(directory);
|
|
const url = new URL("execute/Fileman/list_files", this.config.apiUrl);
|
|
url.searchParams.set("dir", dir);
|
|
url.searchParams.set("include_mime", "0");
|
|
url.searchParams.set("include_permissions", "1");
|
|
url.searchParams.set("limit_to_list", "0");
|
|
url.searchParams.set("show_hidden", "1");
|
|
url.searchParams.set("types", "dir|file|link");
|
|
const payload = await this.request(url);
|
|
const result = uapiResult(payload);
|
|
if (!succeeded(result?.status)) {
|
|
const error = responseError(payload);
|
|
const detail = error === "unknown cPanel error" ? `${error}; ${responseShape(payload)}` : error;
|
|
throw new DeploymentError(`cPanel Fileman list_files failed: ${this.redact(detail)}`);
|
|
}
|
|
if (!Array.isArray(result.data)) {
|
|
throw new DeploymentError("cPanel Fileman list_files returned an unexpected data shape.");
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
async domains() {
|
|
const url = new URL("execute/DomainInfo/domains_data", this.config.apiUrl);
|
|
url.searchParams.set("format", "list");
|
|
const payload = await this.request(url);
|
|
const result = uapiResult(payload);
|
|
if (!succeeded(result?.status)) {
|
|
throw new DeploymentError(`cPanel DomainInfo failed: ${responseError(payload)}`);
|
|
}
|
|
if (!Array.isArray(result.data)) {
|
|
throw new DeploymentError("cPanel DomainInfo returned an unexpected data shape.");
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
assertMutable(remotePath) {
|
|
const safe = safeAccountPath(remotePath);
|
|
if (!this.allowedMutable.has(safe)) {
|
|
throw new DeploymentError(`cPanel mutation outside the explicit restore allowlist: ${safe}.`);
|
|
}
|
|
return safe;
|
|
}
|
|
|
|
async rename(source, destination) {
|
|
return await this.api2("fileop", {
|
|
op: "rename",
|
|
sourcefiles: this.assertMutable(source),
|
|
destfiles: this.assertMutable(destination),
|
|
doubledecode: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
function find(entries, name) {
|
|
return entries.find((entry) => entryName(entry) === name);
|
|
}
|
|
|
|
function publicEntry(entry) {
|
|
if (!entry) return null;
|
|
return {
|
|
name: entryName(entry),
|
|
type: normalizedType(entry),
|
|
mode: String(entry.mode || entry.permissions || ""),
|
|
modified: String(entry.mtime || entry.modified || ""),
|
|
size: String(entry.size ?? entry.filesize ?? ""),
|
|
};
|
|
}
|
|
|
|
function domainRoot(config, domain) {
|
|
return normalizeAccountPath(config, domain?.documentroot || domain?.document_root || domain?.docroot || "");
|
|
}
|
|
|
|
function recoveryPattern(webroot) {
|
|
return new RegExp(`^${webroot}[-._](?:recovery|backup|before[-._]atomic)[-._][A-Za-z0-9._-]+$`, "i");
|
|
}
|
|
|
|
function inspectedDomain(config, domain, index) {
|
|
const name = String(domain?.domain || domain?.servername || "").trim();
|
|
const documentRoot = domainRoot(config, domain);
|
|
if (!name || !documentRoot) {
|
|
throw new DeploymentError(`cPanel DomainInfo returned incomplete domain data at index ${index}.`);
|
|
}
|
|
return {
|
|
domain: name,
|
|
type: String(domain?.domain_type || domain?.type || ""),
|
|
documentRoot,
|
|
};
|
|
}
|
|
|
|
export async function auditRoot(config, options = {}) {
|
|
const client = options.client || new CpanelAccountClient(config, options);
|
|
const [homeEntries, deploymentEntries, domains] = await Promise.all([
|
|
client.list("."),
|
|
client.list(config.deploymentRoot),
|
|
client.domains(),
|
|
]);
|
|
const currentEntry = find(deploymentEntries, "current");
|
|
const currentPath = `${config.deploymentRoot}/current`;
|
|
const requiredReleaseFiles = ["index.html", ".htaccess", "release-manifest.json", "release-entry.json"];
|
|
const current = {
|
|
path: currentPath,
|
|
type: normalizedType(currentEntry),
|
|
accessible: false,
|
|
missingFiles: requiredReleaseFiles,
|
|
error: "",
|
|
};
|
|
if (currentEntry && normalizedType(currentEntry) === "link") {
|
|
try {
|
|
const currentEntries = await client.list(currentPath);
|
|
current.accessible = true;
|
|
current.missingFiles = requiredReleaseFiles.filter((name) => !find(currentEntries, name));
|
|
} catch (error) {
|
|
current.error = error instanceof Error ? error.message : "Could not follow the current link.";
|
|
}
|
|
} else {
|
|
current.error = currentEntry
|
|
? "The deployment current entry is not a symbolic link."
|
|
: "The deployment current entry is missing.";
|
|
}
|
|
const root = publicEntry(find(homeEntries, config.webroot));
|
|
const rootAccess = { accessible: false, error: "" };
|
|
let rootEntries = [];
|
|
if (root) {
|
|
try {
|
|
rootEntries = await client.list(config.webroot);
|
|
rootAccess.accessible = true;
|
|
} catch (error) {
|
|
rootAccess.error = error instanceof Error ? error.message : "Could not inspect the primary webroot.";
|
|
}
|
|
} else {
|
|
rootAccess.error = "The primary webroot entry is missing.";
|
|
}
|
|
const recoveryCandidates = homeEntries
|
|
.map((entry) => publicEntry(entry))
|
|
.filter((entry) => entry && recoveryPattern(config.webroot).test(entry.name))
|
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
const domainRoots = domains
|
|
.map((domain, index) => inspectedDomain(config, domain, index))
|
|
.sort((left, right) =>
|
|
[left.domain, left.documentRoot, left.type]
|
|
.join("\0")
|
|
.localeCompare([right.domain, right.documentRoot, right.type].join("\0"))
|
|
);
|
|
const nestedDomainRoots = domainRoots.filter(({ documentRoot }) => documentRoot.startsWith(`${config.webroot}/`));
|
|
const state = {
|
|
webroot: root,
|
|
webrootAccess: rootAccess,
|
|
webrootEntries: rootEntries.map((entry) => publicEntry(entry)).filter(Boolean),
|
|
current,
|
|
recoveryCandidates,
|
|
domainRoots,
|
|
nestedDomainRoots,
|
|
};
|
|
const stateToken = crypto.createHash("sha256").update(JSON.stringify(state)).digest("hex");
|
|
return {
|
|
...state,
|
|
stateToken,
|
|
rootTargetVerified: false,
|
|
healthy: false,
|
|
};
|
|
}
|
|
|
|
export async function verifyFrontend(config, options = {}) {
|
|
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
|
const checks = [
|
|
["", "html"],
|
|
["index.html", "html"],
|
|
["release-manifest.json", "json"],
|
|
["guest/book/wash", "html"],
|
|
];
|
|
for (const [pathname, expected] of checks) {
|
|
const requestedUrl = new URL(pathname, config.frontendUrl);
|
|
const response = await fetchImpl(requestedUrl, {
|
|
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
|
|
redirect: "follow",
|
|
signal: AbortSignal.timeout(options.timeoutMs || 30_000),
|
|
});
|
|
if (!response.ok) throw new DeploymentError(`Live ${pathname || "/"} returned HTTP ${response.status}.`);
|
|
if (response.url && new URL(response.url).origin !== requestedUrl.origin) {
|
|
throw new DeploymentError(`Live ${pathname || "/"} redirected outside the production frontend origin.`);
|
|
}
|
|
const body = await response.text();
|
|
if (body.includes("Index of /")) throw new DeploymentError("The production root still exposes a directory index.");
|
|
if (expected === "json") {
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(body);
|
|
} catch {
|
|
throw new DeploymentError(`Live ${pathname} did not return JSON.`);
|
|
}
|
|
if (!/^[a-f0-9]{40}$/i.test(String(manifest?.commit_sha || "")) || !String(manifest?.build_id || "")) {
|
|
throw new DeploymentError(`Live ${pathname} did not identify a packaged frontend release.`);
|
|
}
|
|
} else if (!/<div\s+id=["']app["']/i.test(body) || !/<title>[^<]*truck\s*wash/i.test(body)) {
|
|
throw new DeploymentError(`Live ${pathname || "/"} did not return the frontend HTML shell.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function homeNames(client) {
|
|
return new Set((await client.list(".")).map((entry) => entryName(entry)).filter(Boolean));
|
|
}
|
|
|
|
async function renameWithReconciliation(client, source, destination) {
|
|
try {
|
|
await client.rename(source, destination);
|
|
return;
|
|
} catch (error) {
|
|
let names;
|
|
try {
|
|
names = await homeNames(client);
|
|
} catch (inspectionError) {
|
|
throw new DeploymentError(`Could not reconcile the cPanel rename from ${source} to ${destination}.`, {
|
|
cause: new AggregateError([error, inspectionError]),
|
|
});
|
|
}
|
|
const sourceExists = names.has(source);
|
|
const destinationExists = names.has(destination);
|
|
if (!sourceExists && destinationExists) return;
|
|
if (sourceExists && !destinationExists) {
|
|
throw new DeploymentError(`cPanel did not rename ${source} to ${destination}; the source remains in place.`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
throw new DeploymentError(`The cPanel rename from ${source} to ${destination} left an ambiguous account state.`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function reinstatePreRestoreState(client, webroot, recovery, failed) {
|
|
const errors = [];
|
|
let names;
|
|
try {
|
|
names = await homeNames(client);
|
|
} catch (error) {
|
|
return [error];
|
|
}
|
|
|
|
const restored = () => names.has(webroot) && names.has(recovery) && !names.has(failed);
|
|
if (restored()) return errors;
|
|
|
|
if (names.has(webroot) && !names.has(recovery) && names.has(failed)) {
|
|
try {
|
|
await renameWithReconciliation(client, webroot, recovery);
|
|
names = await homeNames(client);
|
|
} catch (error) {
|
|
errors.push(error);
|
|
return errors;
|
|
}
|
|
}
|
|
|
|
if (!names.has(webroot) && names.has(recovery) && names.has(failed)) {
|
|
try {
|
|
await renameWithReconciliation(client, failed, webroot);
|
|
names = await homeNames(client);
|
|
} catch (error) {
|
|
errors.push(error);
|
|
return errors;
|
|
}
|
|
}
|
|
|
|
if (!restored()) {
|
|
errors.push(
|
|
new DeploymentError(
|
|
`Automatic rollback could not prove the required entries: ${webroot}, ${recovery}, and no ${failed}.`
|
|
)
|
|
);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export async function restoreRoot(config, recovery, expectedStateToken, confirmation, options = {}) {
|
|
const safeRecovery = safeAccountPath(recovery, "recovery path");
|
|
if (!recoveryPattern(config.webroot).test(safeRecovery)) {
|
|
throw new DeploymentError(
|
|
"Recovery path must name a retained public_html recovery, backup, or before-atomic entry."
|
|
);
|
|
}
|
|
if (!/^[a-f0-9]{64}$/.test(expectedStateToken)) {
|
|
throw new DeploymentError("Restore requires the exact state token emitted by the audit.");
|
|
}
|
|
const expectedConfirmation = `RESTORE ${safeRecovery} TO ${config.webroot} STATE ${expectedStateToken}`;
|
|
if (confirmation !== expectedConfirmation) {
|
|
throw new DeploymentError(`Confirmation must exactly match: ${expectedConfirmation}`);
|
|
}
|
|
const runId = String(
|
|
options.runId || [process.env.GITHUB_RUN_ID, process.env.GITHUB_RUN_ATTEMPT].filter(Boolean).join("-") || Date.now()
|
|
);
|
|
if (!SAFE_COMPONENT.test(runId)) throw new DeploymentError("Restore run ID is unsafe.");
|
|
const failed = `${config.webroot}.failed-${runId}`;
|
|
const allowedMutable = [config.webroot, safeRecovery, failed];
|
|
const client = options.client || new CpanelAccountClient(config, { ...options, allowedMutable });
|
|
if (client.allowedMutable instanceof Set) {
|
|
for (const item of allowedMutable) client.allowedMutable.add(item);
|
|
}
|
|
const before = await auditRoot(config, { ...options, client });
|
|
if (before.stateToken !== expectedStateToken) {
|
|
throw new DeploymentError("The cPanel webroot changed after the audit; run a new audit before restoring.");
|
|
}
|
|
const selectedRecovery = before.recoveryCandidates.find((entry) => entry.name === safeRecovery);
|
|
if (!selectedRecovery) {
|
|
throw new DeploymentError("The requested recovery entry does not exist in the current cPanel state.");
|
|
}
|
|
if (selectedRecovery.type !== "dir") {
|
|
throw new DeploymentError(
|
|
"Automatic restore requires a physical retained directory; cPanel Fileman may follow symbolic links."
|
|
);
|
|
}
|
|
if (!before.webroot)
|
|
throw new DeploymentError("The current public_html entry is missing; refusing an ambiguous restore.");
|
|
if (before.webroot.type !== "dir") {
|
|
throw new DeploymentError(
|
|
"Automatic restore requires a physical current webroot; cPanel Fileman may follow symbolic links."
|
|
);
|
|
}
|
|
if (!before.webrootAccess.accessible) {
|
|
throw new DeploymentError("The current public_html directory could not be inspected.");
|
|
}
|
|
if (before.nestedDomainRoots.length > 0) {
|
|
throw new DeploymentError(
|
|
`Refusing to replace public_html while nested domain document roots exist: ${before.nestedDomainRoots
|
|
.map(({ domain, documentRoot }) => `${domain}=${documentRoot}`)
|
|
.join(", ")}.`
|
|
);
|
|
}
|
|
if (find(await client.list("."), failed)) {
|
|
throw new DeploymentError(`The displaced-state path ${failed} already exists; refusing to overwrite it.`);
|
|
}
|
|
|
|
try {
|
|
await renameWithReconciliation(client, config.webroot, failed);
|
|
await renameWithReconciliation(client, safeRecovery, config.webroot);
|
|
await (options.verify || verifyFrontend)(config, options);
|
|
} catch (error) {
|
|
const rollbackErrors = await reinstatePreRestoreState(client, config.webroot, safeRecovery, failed);
|
|
if (rollbackErrors.length > 0) {
|
|
throw new DeploymentError(
|
|
"The retained webroot failed and automatic rollback was incomplete; both retained cPanel entries were preserved for manual recovery.",
|
|
{ cause: new AggregateError([error, ...rollbackErrors]) }
|
|
);
|
|
}
|
|
throw new DeploymentError("The retained webroot failed live verification; the pre-restore state was reinstated.", {
|
|
cause: error,
|
|
});
|
|
}
|
|
return { restored: safeRecovery, displaced: failed };
|
|
}
|