219 lines
7.7 KiB
Bash
219 lines
7.7 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
MODE="${1:-audit}"
|
|
FTP_HOST="${PRODUCTION_FTP_HOST:-}"
|
|
FTP_USER="${PRODUCTION_FTP_USER:-}"
|
|
FTP_PASSWORD="${PRODUCTION_FTP_PASSWORD:-}"
|
|
FTP_PATH="${PRODUCTION_FTP_PATH:-}"
|
|
WEBROOT="${PRODUCTION_CPANEL_WEBROOT:-public_html}"
|
|
FRONTEND_URL="${PRODUCTION_FRONTEND_URL:-https://truckwash.io}"
|
|
SOURCE_HTACCESS="${SOURCE_HTACCESS:-public/.htaccess}"
|
|
REPORT_DIR="${ROOT_REPAIR_REPORT_DIR:-output/cpanel-root-ftps}"
|
|
RUN_ID="${GITHUB_RUN_ID:-local}"
|
|
RUN_ATTEMPT="${GITHUB_RUN_ATTEMPT:-1}"
|
|
|
|
if [[ "$MODE" != "audit" && "$MODE" != "repair" ]]; then
|
|
echo "Usage: $0 [audit|repair]" >&2
|
|
exit 2
|
|
fi
|
|
if [[ -z "$FTP_HOST" || -z "$FTP_USER" || -z "$FTP_PASSWORD" || -z "$FTP_PATH" ]]; then
|
|
echo "Production FTPS credentials and path are required." >&2
|
|
exit 2
|
|
fi
|
|
if [[ "$FTP_USER" == *$'\n'* || "$FTP_USER" == *$'\r'* || "$FTP_PASSWORD" == *$'\n'* || "$FTP_PASSWORD" == *$'\r'* ]]; then
|
|
echo "Production FTPS credentials contain unsupported control characters." >&2
|
|
exit 2
|
|
fi
|
|
if [[ ! "$FTP_HOST" =~ ^[A-Za-z0-9.-]+(:[0-9]{1,5})?$ ]]; then
|
|
echo "PRODUCTION_FTP_HOST is invalid." >&2
|
|
exit 2
|
|
fi
|
|
|
|
normalized_ftp_path="${FTP_PATH#/}"
|
|
normalized_ftp_path="${normalized_ftp_path%/}"
|
|
if [[ ! "$normalized_ftp_path" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$ ]]; then
|
|
echo "PRODUCTION_FTP_PATH must contain only safe path components." >&2
|
|
exit 2
|
|
fi
|
|
if [[ ! "$WEBROOT" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
|
echo "PRODUCTION_CPANEL_WEBROOT must be one safe path component." >&2
|
|
exit 2
|
|
fi
|
|
if [[ ! "$RUN_ID" =~ ^[A-Za-z0-9._-]+$ || ! "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]; then
|
|
echo "GitHub run identity is invalid." >&2
|
|
exit 2
|
|
fi
|
|
if [[ ! -f "$SOURCE_HTACCESS" ]] || ! grep -Eq '^DirectoryIndex[[:space:]]+index\.html[[:space:]]*$' "$SOURCE_HTACCESS"; then
|
|
echo "The reviewed source .htaccess must declare DirectoryIndex index.html." >&2
|
|
exit 2
|
|
fi
|
|
if ! command -v lftp >/dev/null 2>&1; then
|
|
echo "lftp is required." >&2
|
|
exit 2
|
|
fi
|
|
|
|
mkdir -p "$REPORT_DIR"
|
|
original="$REPORT_DIR/original.htaccess"
|
|
remote_part_copy="$REPORT_DIR/uploaded-part.htaccess"
|
|
pre_activation_copy="$REPORT_DIR/pre-activation.htaccess"
|
|
active_copy="$REPORT_DIR/active.htaccess"
|
|
backup_name=".htaccess.before-directory-index-${RUN_ID}-${RUN_ATTEMPT}"
|
|
part_name=".htaccess.directory-index-${RUN_ID}-${RUN_ATTEMPT}.part"
|
|
failed_name=".htaccess.failed-directory-index-${RUN_ID}-${RUN_ATTEMPT}"
|
|
|
|
lftp_quote() {
|
|
local value="${1//\'/\'\\\'\'}"
|
|
printf "'%s'" "$value"
|
|
}
|
|
|
|
write_connection() {
|
|
printf 'set cmd:fail-exit yes\n'
|
|
printf 'set cmd:interactive no\n'
|
|
printf 'set ftp:ssl-allow yes\n'
|
|
printf 'set ftp:ssl-force yes\n'
|
|
printf 'set ftp:ssl-protect-data yes\n'
|
|
printf 'set ssl:verify-certificate yes\n'
|
|
printf 'set ssl:check-hostname yes\n'
|
|
printf 'set net:max-retries 3\n'
|
|
printf 'set net:timeout 20\n'
|
|
printf 'open -u %s,%s %s\n' \
|
|
"$(lftp_quote "$FTP_USER")" \
|
|
"$(lftp_quote "$FTP_PASSWORD")" \
|
|
"$(lftp_quote "ftp://$FTP_HOST")"
|
|
printf 'cd %s\n' "$(lftp_quote "$FTP_PATH")"
|
|
IFS='/' read -r -a path_components <<< "$normalized_ftp_path"
|
|
for _component in "${path_components[@]}"; do
|
|
printf "cd '..'\n"
|
|
done
|
|
printf 'cd %s\n' "$(lftp_quote "$WEBROOT")"
|
|
}
|
|
|
|
run_lftp() {
|
|
{
|
|
write_connection
|
|
printf '%s\n' "$@"
|
|
printf 'bye\n'
|
|
} | lftp -f /dev/stdin
|
|
}
|
|
|
|
write_report() {
|
|
local state="$1"
|
|
local original_sha source_sha active_sha has_directory_index
|
|
original_sha="$(sha256sum "$original" | awk '{print $1}')"
|
|
source_sha="$(sha256sum "$SOURCE_HTACCESS" | awk '{print $1}')"
|
|
active_sha=""
|
|
if [[ -f "$active_copy" ]]; then
|
|
active_sha="$(sha256sum "$active_copy" | awk '{print $1}')"
|
|
fi
|
|
has_directory_index=false
|
|
if grep -Eq '^DirectoryIndex[[:space:]]+index\.html[[:space:]]*$' "$original"; then
|
|
has_directory_index=true
|
|
fi
|
|
REPORT_STATE="$state" \
|
|
REPORT_WEBROOT="$WEBROOT" \
|
|
REPORT_BACKUP="$backup_name" \
|
|
REPORT_ORIGINAL_SHA="$original_sha" \
|
|
REPORT_SOURCE_SHA="$source_sha" \
|
|
REPORT_ACTIVE_SHA="$active_sha" \
|
|
REPORT_HAS_DIRECTORY_INDEX="$has_directory_index" \
|
|
node --input-type=module <<'NODE' > "$REPORT_DIR/report.json"
|
|
const report = {
|
|
state: process.env.REPORT_STATE,
|
|
webroot: process.env.REPORT_WEBROOT,
|
|
backup: process.env.REPORT_BACKUP,
|
|
originalSha256: process.env.REPORT_ORIGINAL_SHA,
|
|
expectedSha256: process.env.REPORT_SOURCE_SHA,
|
|
activeSha256: process.env.REPORT_ACTIVE_SHA || null,
|
|
originalHasDirectoryIndex: process.env.REPORT_HAS_DIRECTORY_INDEX === "true",
|
|
};
|
|
console.log(`${JSON.stringify(report, null, 2)}\n`);
|
|
NODE
|
|
}
|
|
|
|
verify_live() {
|
|
VERIFY_BASE_URL="$FRONTEND_URL" VERIFY_RUN_ID="$RUN_ID" node --input-type=module <<'NODE'
|
|
const baseUrl = new URL(process.env.VERIFY_BASE_URL);
|
|
const checks = ["/", "/index.html", "/release-manifest.json", "/guest/book/wash"];
|
|
for (const path of checks) {
|
|
const url = new URL(path, baseUrl);
|
|
url.searchParams.set("root-repair", process.env.VERIFY_RUN_ID);
|
|
const response = await fetch(url, { headers: { Accept: "text/html,application/json" } });
|
|
if (!response.ok) throw new Error(`${path} returned HTTP ${response.status}`);
|
|
const body = await response.text();
|
|
if ((path === "/" || path === "/index.html" || path === "/guest/book/wash") &&
|
|
(!body.includes('<div id="app"') || body.includes("Index of /"))) {
|
|
throw new Error(`${path} did not render the Vue application shell`);
|
|
}
|
|
}
|
|
console.log("Live root, index, manifest, and deep route verified.");
|
|
NODE
|
|
}
|
|
|
|
run_lftp "get $(lftp_quote '.htaccess') -o $(lftp_quote "$original")"
|
|
write_report "audited"
|
|
|
|
if [[ "$MODE" == "audit" ]]; then
|
|
cat "$REPORT_DIR/report.json"
|
|
exit 0
|
|
fi
|
|
|
|
source_sha="$(sha256sum "$SOURCE_HTACCESS" | awk '{print $1}')"
|
|
original_sha="$(sha256sum "$original" | awk '{print $1}')"
|
|
if [[ "$source_sha" == "$original_sha" ]]; then
|
|
cp "$original" "$active_copy"
|
|
verify_live
|
|
write_report "already-current"
|
|
cat "$REPORT_DIR/report.json"
|
|
exit 0
|
|
fi
|
|
|
|
run_lftp \
|
|
"put $(lftp_quote "$SOURCE_HTACCESS") -o $(lftp_quote "$part_name")" \
|
|
"get $(lftp_quote "$part_name") -o $(lftp_quote "$remote_part_copy")" \
|
|
"get $(lftp_quote '.htaccess') -o $(lftp_quote "$pre_activation_copy")"
|
|
if [[ "$(sha256sum "$remote_part_copy" | awk '{print $1}')" != "$source_sha" ]]; then
|
|
echo "Uploaded .htaccess staging file failed checksum verification." >&2
|
|
exit 1
|
|
fi
|
|
if [[ "$(sha256sum "$pre_activation_copy" | awk '{print $1}')" != "$original_sha" ]]; then
|
|
echo "Live .htaccess changed after audit; refusing to overwrite it." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! run_lftp "mv $(lftp_quote '.htaccess') $(lftp_quote "$backup_name")"; then
|
|
echo "Could not retain the original .htaccess; no activation was attempted." >&2
|
|
exit 1
|
|
fi
|
|
if ! run_lftp "mv $(lftp_quote "$part_name") $(lftp_quote '.htaccess')"; then
|
|
run_lftp "mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
|
|
echo "FTPS activation failed; rollback was attempted." >&2
|
|
exit 1
|
|
fi
|
|
if ! run_lftp "get $(lftp_quote '.htaccess') -o $(lftp_quote "$active_copy")"; then
|
|
run_lftp \
|
|
"mv $(lftp_quote '.htaccess') $(lftp_quote "$failed_name")" \
|
|
"mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
|
|
echo "Could not verify the active .htaccess; rollback was attempted." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$(sha256sum "$active_copy" | awk '{print $1}')" != "$source_sha" ]]; then
|
|
run_lftp \
|
|
"mv $(lftp_quote '.htaccess') $(lftp_quote "$failed_name")" \
|
|
"mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
|
|
echo "Active .htaccess checksum mismatched; rollback was attempted." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! verify_live; then
|
|
run_lftp \
|
|
"mv $(lftp_quote '.htaccess') $(lftp_quote "$failed_name")" \
|
|
"mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
|
|
echo "Live verification failed; the original .htaccess was restored." >&2
|
|
exit 1
|
|
fi
|
|
|
|
write_report "repaired"
|
|
cat "$REPORT_DIR/report.json"
|