Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfbf80f327 | ||
|
|
96958c53a3 | ||
|
|
13509b68f2 | ||
|
|
f689409990 |
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user