#!/bin/sh
# Upgrade installed, versioned PHP packages only; verify both PHP2Shell fixes.
# --check [major.minor] is read-only. --upgrade [major.minor] refreshes APT,
# upgrades all installed packages in scope, verifies, and replaces FPM workers.
# Never invoke --upgrade synchronously from dpkg postinst (it holds the lock).
set -eu
export LC_ALL=C
mode=${1:---check}
branch=${2:-}
case "$mode" in --check|--upgrade) ;; *) echo 'usage: maxpanel-php-security --check|--upgrade [major.minor]' >&2; exit 2;; esac
if [ -n "$branch" ]; then
  printf '%s\n' "$branch" | grep -Eq '^[578]\.[0-9]+$' || exit 2
fi
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT HUP INT TERM
snapshot() {
  dpkg-query -W -f='${Package}\t${Version}\t${db:Status-Status}\n' > "$work/all"
  awk -F '\t' -v branch="$branch" '$3 == "installed" && $1 ~ /^(php|libapache2-mod-php|libphp)[578]\.[0-9]+(-|$)/ {
    v=$1; sub(/^(php|libapache2-mod-php|libphp)/, "", v); sub(/-.*/, "", v);
    if (branch == "" || v == branch) print $1 "\t" $2 "\t" v
  }' "$work/all" | sort > "$work/packages"
}
# Only upstream stable release numbers meet the upstream floor. Debian
# revisions are compared with dpkg; pre-releases (~rc etc.) remain below it.
fixed() {
  package=$1; version=$2; v=$3
  case "$v" in
    8.2) floor=8.2.35;; 8.3) floor=8.3.36;;
    8.4) floor=8.4.27;; 8.5) floor=8.5.12;; *) floor=;;
  esac
  case "$package" in
    php"$v"-common|php"$v"-fpm|php"$v"-cli|php"$v"-cgi|php"$v"-phpdbg|php"$v"-litespeed|libapache2-mod-php"$v"|libphp"$v"-embed) ;;
    *) return 0;; # Extensions are upgraded, but do not ship the PHP engine.
  esac
  upstream=${version#*:}; upstream=${upstream%%-*}
  if [ -n "$floor" ] && dpkg --compare-versions "$upstream" ge "$floor"; then return 0; fi
  # Vendor backports preserve the upstream release number. Require the
  # installed package's own changelog to record BOTH fixes. No remote
  # candidate changelog or version-number guess counts as installed proof.
  docroot=${MAXPANEL_PHP_DOC_ROOT:-/usr/share/doc}
  doc="$docroot/$package/changelog.Debian.gz"
  [ -r "$doc" ] || return 1
  gzip -cd "$doc" > "$work/changelog" || return 1
  # Ensure the changelog belongs to this installed package version.
  head -n 1 "$work/changelog" | grep -F "($version)" >/dev/null || return 1
  grep -F 'CVE-2026-109618' "$work/changelog" >/dev/null &&
    grep -F 'CVE-2026-109619' "$work/changelog" >/dev/null
}
snapshot
if [ -n "$branch" ] && ! grep -q "^php$branch-fpm$(printf '\t')" "$work/packages"; then
  echo "PHP $branch FPM is not installed" >&2; exit 1
fi
[ -s "$work/packages" ] || exit 0
if [ "$mode" = --upgrade ]; then
  # Serialise background remediation with agent-triggered remediation.
  exec 9>"${MAXPANEL_PHP_LOCK_FILE:-/run/lock/maxpanel-php-security.lock}"
  flock -w 300 9 || exit 1
  apt-get -o DPkg::Lock::Timeout=300 -o APT::Update::Error-Mode=any update
  # Package names come from dpkg, are whitespace-free, and must be split.
  # shellcheck disable=SC2046
  set -- $(cut -f1 "$work/packages")
  DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 install -y --only-upgrade "$@"
  snapshot
fi
failed=0
cut -f3 "$work/packages" | sort -u > "$work/branches"
while IFS= read -r v; do
  awk -F '\t' -v v="$v" '$3 == v' "$work/packages" > "$work/current"
  safe=1
  while IFS="$(printf '\t')" read -r package version _branch; do
    if ! fixed "$package" "$version" "$v"; then
      echo "UNRESOLVED: $package $version has no verified fix for BOTH CVE-2026-109618 and CVE-2026-109619; install a vendor backport or migrate this branch" >&2
      safe=0; failed=1
    fi
  done < "$work/current"
  state=${MAXPANEL_PHP_STATE_DIR:-/var/lib/maxpanel-php-security}
  if [ "$safe" = 1 ] && [ "$mode" = --check ] && ! cmp -s "$work/current" "$state/$v.verified"; then
    echo "PHP $v packages are patched but worker replacement has not been verified; run --upgrade" >&2
    failed=1
  fi
  if [ "$safe" = 1 ] && [ "$mode" = --upgrade ]; then
    install -d -o root -g root -m 0700 "$state"
    # Replace active workers without starting services an operator stopped.
    # Save only after config validation AND worker replacement succeeds.
    # A failed restart is retried even when APT has nothing more to upgrade.
    if ! cmp -s "$work/current" "$state/$v.verified"; then
      if grep -q "^php$v-fpm$(printf '\t')" "$work/current"; then
        if ! "php-fpm$v" -t || ! systemctl try-restart "php$v-fpm.service"; then
          echo "PHP $v worker replacement failed; retry required" >&2
          failed=1; continue
        fi
      fi
      if grep -q "^libapache2-mod-php$v$(printf '\t')" "$work/current"; then
        if ! apache2ctl configtest || ! systemctl try-restart apache2.service; then
          echo "Apache PHP $v worker replacement failed; retry required" >&2
          failed=1; continue
        fi
      fi
      cp "$work/current" "$state/$v.verified"
    fi
    echo "PHP $v: both CVE fixes verified; FPM workers replaced or already current"
  fi
done < "$work/branches"
exit "$failed"
