Compare commits

...
3 changed files with 305 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
name: Frontend Root FTPS Repair
on:
workflow_dispatch:
inputs:
mode:
description: Audit downloads and hashes .htaccess; repair backs it up and activates the reviewed file.
required: true
default: audit
type: choice
options:
- audit
- repair
permissions:
contents: read
concurrency:
group: frontend-production
cancel-in-progress: false
jobs:
audit-or-repair:
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 10
environment:
name: frontend-production
url: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v5
with:
node-version: 22
- name: Install secure FTP client without system changes
run: |
if command -v lftp >/dev/null 2>&1; then
exit 0
fi
package_root="$RUNNER_TEMP/lftp-package"
mkdir -p "$package_root"
(
cd "$package_root"
apt-get download lftp
dpkg-deb --extract ./lftp_*.deb root
)
echo "$package_root/root/usr/bin" >> "$GITHUB_PATH"
- name: Audit or repair live root .htaccess
run: bash scripts/release/repair-public-htaccess.sh "${{ inputs.mode }}"
env:
NODE_OPTIONS: --use-system-ca
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
ROOT_REPAIR_REPORT_DIR: output/cpanel-root-ftps
- name: Upload FTPS root report
if: always()
uses: actions/upload-artifact@v4
with:
name: frontend-root-ftps-${{ inputs.mode }}-${{ github.run_id }}
path: output/cpanel-root-ftps
if-no-files-found: ignore
retention-days: 30
+15
View File
@@ -207,6 +207,21 @@ account-home entries and reinstates the pre-restore cPanel state. It never
deletes the recovery or displaced webroot, and reports manual intervention if deletes the recovery or displaced webroot, and reports manual intervention if
the expected entries cannot be proven after compensation. the expected entries cannot be proven after compensation.
If Imunify360 blocks the cPanel API before the audit can read Fileman, use the
protected **Frontend Root FTPS Repair** workflow. Start with `audit`: it reads
and hashes only the active `.htaccess`. The `repair` mode stages the reviewed
`public/.htaccess`, verifies its checksum, rechecks that the live file has not
changed since the audit, and retains the original as a run-specific backup
before activation. It then verifies the public root, `index.html`, release
manifest, and a deep Vue route; a failed checksum or live check restores the
original file and retains the failed candidate for inspection.
This FTPS repair is deliberately limited to `.htaccess`. It restores direct
SPA loading when a valid release is already present but does not replace the
atomic cPanel release workflow. The hosting administrator must still whitelist
the automation source in Imunify360 WebShield before normal deployments can
resume.
## Caching and compatibility ## Caching and compatibility
The release `.htaccess` gives exact eight-character Vite-fingerprinted assets a The release `.htaccess` gives exact eight-character Vite-fingerprinted assets a
+218
View File
@@ -0,0 +1,218 @@
#!/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"