Saltar al contenido principal
Teramont Logo
Error 413 en Nginx: cómo localizar y corregir el límite de subida
Volver al blog

Error 413 en Nginx: cómo localizar y corregir el límite de subida

Mizael Segovia

17/8/2026 ·Mizael Segovia· 94 min de lectura ·

1 visualizaciones

Un error 413 significa que alguna capa se niega a procesar el cuerpo de la solicitud porque supera el tamaño que acepta. Antes de cambiar Nginx, identifica si la respuesta nace en un CDN/WAF o proxy anterior, en Nginx, o después, en PHP-FPM, WordPress o tu aplicación. La corrección segura consiste en ajustar el límite mínimo necesario en la capa y el alcance correctos, validar la configuración y recargar solo cuando la prueba pase.

El nombre actual en RFC 9110 §15.5.14 es 413 Content Too Large; todavía verás el mensaje histórico Request Entity Too Large. El código describe el resultado, no identifica qué servidor de la cadena lo generó.

Antes de empezar: alcance y requisitos

Este procedimiento está orientado a Ubuntu 24.04/26.04 o Debian equivalente, con Nginx, acceso sudo y una aplicación legítima que necesita recibir una solicitud mayor. Los ejemplos usan WordPress con PHP-FPM, pero el diagnóstico por capas también sirve para una API Node.js. Adapta dominio, ruta, versión de PHP, unidad systemd y endpoint; no pegues valores de ejemplo en producción sin comprobarlos.

Conserva un snapshot o respaldo recuperable, abre una segunda sesión SSH y registra la hora exacta del fallo. Confirma que puedes volver a la configuración anterior antes de editar. Estos son comandos documentados y revisados de forma estática: no se ejecutaron contra tu VPS ni demuestran que una capa concreta sea la causa.

1. Localiza la capa que devuelve el 413

Repite primero la operación legítima sin aumentar el archivo. Guarda código de estado, encabezados, fragmento no sensible del cuerpo, URL y hora con zona. Compara esa hora con los registros de cada salto. Un encabezado de marca es una pista, no una atribución suficiente: un proxy puede modificarlo y una página personalizada puede ocultar el origen real.

Señales del 413 y siguiente prueba con menor riesgo
SeñalCapa probablePrueba siguiente
El 413 aparece en el borde y no existe una entrada contemporánea en Nginx.CDN, WAF o proxy anterior.Revisa sus eventos y límites; prueba el origen de forma controlada con el mismo nombre TLS, sin publicar su IP ni desactivar protecciones globales.
El error log de Nginx registra que el cuerpo es demasiado grande.Nginx.Obtén nginx -T y localiza el bloque que atiende dominio y ruta.
Nginx reenvía la solicitud y PHP deja $_POST/$_FILES vacíos o WordPress muestra un límite menor.PHP-FPM o WordPress.Identifica SAPI, versión, pool e INI activos; compara ambos límites PHP.
Nginx y PHP aceptan el cuerpo, pero la aplicación devuelve 413 o un error propio.Node.js, framework o servicio upstream.Consulta sus logs y la configuración del parser o endpoint, sin atribuirlo a Nginx.
El límite parece correcto, pero la petición falla al crecer.Almacenamiento temporal, permisos o capacidad.Revisa espacio e inodos en las rutas temporales efectivas de Nginx/PHP y sus errores.

Guarda el siguiente bloque como un script regular root-owned en modo 0700 y ejecútalo con before. Los sentinelas abortan hasta que indiques el mismo método, URL, campo multipart, archivo no sensible y autenticación de la operación legítima. El archivo y la autenticación deben ser copias privadas root-owned 0600: el script vuelve a copiarlos dentro de un directorio 0700, hace que curl lea exclusivamente esos snapshots y verifica sus hashes después del envío. Headers, cuerpo, configuración y logs pueden contener secretos, IP y request IDs; redacta cualquier copia antes de compartirla.

Toma la identidad de Nginx del ExecStart tipado de la interfaz D-Bus de systemd, no de /proc/MainPID/cmdline: el código de Nginx muestra que sobrescribe ese título. El binario debe coincidir con /proc/MainPID/exe; conserva el vector completo, incluidos -p, -c, -g y -e, para -t/-T. Si D-Bus no entrega una orden inequívoca, detente y consulta al administrador. El inventario confirma server, location, logs y fastcgi_pass, pero no resuelve precedencia.

set -euo pipefail

die() { echo "ERROR: $*" >&2; exit 1; }
hash_file() { sha256sum -- "$1" | awk '{print $1}'; }
require_private_file() {
  local FILE="$1" LABEL="$2"
  [[ "$FILE" == /* && "$FILE" != /REPLACE* ]] || die "Replace ${LABEL} with an absolute path"
  [[ -f "$FILE" && ! -L "$FILE" ]] || die "${LABEL} must be a regular, non-symlink file"
  [[ "$(stat -Lc '%u:%a' -- "$FILE")" == '0:600' ]] || die "${LABEL} must be root-owned mode 600"
}
copy_frozen() {
  local SOURCE="$1" DEST="$2" BEFORE AFTER COPIED
  BEFORE="$(hash_file "$SOURCE")"
  cp --no-dereference -- "$SOURCE" "$DEST"
  chown root:root "$DEST"
  chmod 600 "$DEST"
  [[ -f "$DEST" && ! -L "$DEST" && "$(stat -Lc '%u:%a' -- "$DEST")" == '0:600' ]] || die "Unsafe snapshot: $DEST"
  COPIED="$(hash_file "$DEST")"
  AFTER="$(hash_file "$SOURCE")"
  [[ "$BEFORE" == "$COPIED" && "$COPIED" == "$AFTER" ]] || die "Source changed while snapshotting: $SOURCE"
}
[[ "$EUID" -eq 0 ]] || die 'Run the reviewed script as root'
for COMMAND in busctl curl jq journalctl sha256sum systemctl; do
  command -v "$COMMAND" >/dev/null || die "Missing required command: $COMMAND"
done

PHASE="${1:-REPLACE_PHASE}"
CASE_ID='REPLACE_CASE_ID'
METHOD='REPLACE_METHOD'
URL='https://REPLACE/upload'
FIELD='REPLACE_MULTIPART_FIELD'
UPLOAD_FILE='/REPLACE/private/non-sensitive-test.bin'
AUTH_CONFIG='/REPLACE/private/curl-auth.conf'
ERROR_LOG='/REPLACE/effective-error-log-or-NONE'
ACCESS_LOG='/REPLACE/effective-access-log-or-NONE'
UNIT='nginx.service'

[[ "$PHASE" == 'before' || "$PHASE" == 'after' ]] || die 'PHASE must be before or after'
[[ "$CASE_ID" != REPLACE* && "$CASE_ID" =~ ^[a-zA-Z0-9._-]+$ ]] || die 'Replace CASE_ID'
case "$METHOD" in POST|PUT|PATCH) ;; *) die 'Replace METHOD with the legitimate multipart method' ;; esac
[[ "$URL" == https://* && "$URL" != https://REPLACE* && "$URL" != *$'\n'* && "$URL" != *'#'* ]] || die 'Replace URL with the exact HTTPS endpoint'
[[ "$URL" != *'://'[^/]*'@'* ]] || die 'Keep credentials out of URL'
[[ "$FIELD" != REPLACE* && "$FIELD" =~ ^[a-zA-Z0-9_.-]+$ ]] || die 'Replace FIELD'
require_private_file "$UPLOAD_FILE" UPLOAD_FILE
require_private_file "$AUTH_CONFIG" AUTH_CONFIG
UPLOAD_NAME="$(basename -- "$UPLOAD_FILE")"
[[ "$UPLOAD_NAME" =~ ^[a-zA-Z0-9._-]+$ ]] || die 'Use a safe, non-sensitive upload filename'
grep -Eq '^[[:space:]]*header[[:space:]]*=' "$AUTH_CONFIG" || die 'AUTH_CONFIG must contain an authentication header'
grep -Fq 'REPLACE' "$AUTH_CONFIG" && die 'Replace the auth placeholder without printing it'
if grep -Evq '^[[:space:]]*(#.*|header[[:space:]]*=.*)?$' "$AUTH_CONFIG"; then
  die 'AUTH_CONFIG may contain only comments, blank lines, and header directives'
fi

check_log_setting() {
  local VALUE="$1"
  [[ "$VALUE" != /REPLACE* ]] || die 'Replace each effective log path, or use NONE only for syslog/off'
  if [[ "$VALUE" != 'NONE' ]]; then
    [[ "$VALUE" == /* && -f "$VALUE" && ! -L "$VALUE" ]] || die "Invalid effective log file: $VALUE"
  fi
}
check_log_setting "$ERROR_LOG"
check_log_setting "$ACCESS_LOG"

SCRIPT_SOURCE="$(readlink -f -- "${BASH_SOURCE[0]}")"
[[ -f "$SCRIPT_SOURCE" && ! -L "$SCRIPT_SOURCE" && "$(stat -Lc '%u:%a' -- "$SCRIPT_SOURCE")" == '0:700' ]] || die 'Save this reviewed script as a root-owned mode-700 regular file'
SCRIPT_SOURCE_HASH="$(hash_file "$SCRIPT_SOURCE")"
STATE_FILE="/root/nginx-413-${CASE_ID}-${PHASE}.state.json"
[[ ! -e "$STATE_FILE" && ! -L "$STATE_FILE" ]] || die "State already exists: $STATE_FILE"
if [[ "$PHASE" == 'after' ]]; then
  BEFORE_STATE="/root/nginx-413-${CASE_ID}-before.state.json"
  [[ -f "$BEFORE_STATE" && ! -L "$BEFORE_STATE" && "$(stat -Lc '%u:%a' -- "$BEFORE_STATE")" == '0:600' ]] || die 'Protected before state is missing; do not invent a baseline'
fi

get_unit_path() {
  local REPLY
  REPLY="$(busctl --json=short call org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager GetUnit s "$UNIT")" || die 'D-Bus GetUnit failed'
  UNIT_PATH="$(jq -er '
    select(.type == "o") |
    if (.data|type) == "string" then .data
    elif ((.data|type) == "array" and (.data|length) == 1 and (.data[0]|type) == "string") then .data[0]
    else error("ambiguous object path") end
  ' <<<"$REPLY")" || die 'Cannot decode the unit object path unambiguously; stop and consult the administrator'
  [[ "$UNIT_PATH" == /org/freedesktop/systemd1/unit/* ]] || die 'Unexpected unit object path'
}

get_unit_path
EXEC_JSON="$(busctl --json=short get-property org.freedesktop.systemd1 "$UNIT_PATH" org.freedesktop.systemd1.Service ExecStart)" || die 'Cannot read structured ExecStart'
jq -e '
  .type == "a(sasbttttuii)" and (.data|type) == "array" and (.data|length) == 1 and
  (.data[0]|type) == "array" and (.data[0]|length) == 10 and
  (.data[0][0]|type) == "string" and (.data[0][1]|type) == "array" and (.data[0][1]|length) >= 1 and
  all(.data[0][1][]; type == "string")
' <<<"$EXEC_JSON" >/dev/null || die 'ExecStart is not one unambiguous structured command; stop and consult the administrator'
mapfile -d '' -t EXEC_ARGV < <(jq -j '.data[0][1][] | ., "\u0000"' <<<"$EXEC_JSON")
EXEC_PATH="$(jq -er '.data[0][0]' <<<"$EXEC_JSON")"
[[ "$EXEC_PATH" == /* && "${EXEC_ARGV[0]}" == /* ]] || die 'ExecStart binary paths must be absolute'
UNIT_BIN="$(readlink -f -- "$EXEC_PATH")"
ARGV0_BIN="$(readlink -f -- "${EXEC_ARGV[0]}")"
MAINPID="$(systemctl show --property=MainPID --value "$UNIT")"
[[ "$MAINPID" =~ ^[1-9][0-9]*$ && -e "/proc/${MAINPID}/exe" ]] || die 'The exact Nginx unit has no readable MainPID'
PROC_BIN="$(readlink -f -- "/proc/${MAINPID}/exe")"
[[ -x "$UNIT_BIN" && "$UNIT_BIN" == "$ARGV0_BIN" && "$UNIT_BIN" == "$PROC_BIN" ]] || die 'Structured ExecStart, argv[0], and /proc/MainPID/exe do not identify one binary'
NGINX_BIN="$UNIT_BIN"
NGINX_CONTEXT=("${EXEC_ARGV[@]:1}")
for ARG in "${NGINX_CONTEXT[@]}"; do
  case "$ARG" in -s|-s*|-t|-T|-q|-v|-V|-h|-\?) die 'ExecStart contains an action flag unsafe to reuse for validation' ;; esac
done

resolve_main_config() {
  local CONF='' PREFIX='' CONF_COUNT=0 PREFIX_COUNT=0 I VERSION COMPILED_CONF COMPILED_PREFIX
  for ((I=0; I<${#NGINX_CONTEXT[@]}; I++)); do
    case "${NGINX_CONTEXT[I]}" in
      -c) ((I+1 < ${#NGINX_CONTEXT[@]})) || die 'Structured -c has no value'; CONF="${NGINX_CONTEXT[I+1]}"; ((CONF_COUNT+=1)); ((I+=1)) ;;
      -c*) CONF="${NGINX_CONTEXT[I]#-c}"; ((CONF_COUNT+=1)) ;;
      -p) ((I+1 < ${#NGINX_CONTEXT[@]})) || die 'Structured -p has no value'; PREFIX="${NGINX_CONTEXT[I+1]}"; ((PREFIX_COUNT+=1)); ((I+=1)) ;;
      -p*) PREFIX="${NGINX_CONTEXT[I]#-p}"; ((PREFIX_COUNT+=1)) ;;
    esac
  done
  (( CONF_COUNT <= 1 && PREFIX_COUNT <= 1 )) || die 'Structured -c/-p options are ambiguous'
  VERSION="$("$NGINX_BIN" -V 2>&1)" || die 'nginx -V failed'
  COMPILED_CONF="$(grep -oE -- '--conf-path=[^[:space:]]+' <<<"$VERSION" | sed 's/^--conf-path=//' | awk 'NR==1{v=$0} END{if(NR==1) print v}')"
  COMPILED_PREFIX="$(grep -oE -- '--prefix=[^[:space:]]+' <<<"$VERSION" | sed 's/^--prefix=//' | awk 'NR==1{v=$0} END{if(NR==1) print v}')"
  [[ -n "$COMPILED_CONF" ]] || die 'Compiled --conf-path is missing or ambiguous'
  if [[ -z "$PREFIX" ]]; then PREFIX="$COMPILED_PREFIX"; fi
  if [[ -z "$CONF" ]]; then CONF="$COMPILED_CONF"; fi
  if [[ "$CONF" != /* ]]; then
    [[ "$PREFIX" == /* ]] || die 'Relative main config has no unambiguous absolute prefix'
    CONF="${PREFIX%/}/$CONF"
  fi
  [[ -f "$CONF" && ! -L "$CONF" ]] || die 'Resolved main config is not a regular, non-symlink file'
  NGINX_CONF="$(readlink -f -- "$CONF")"
}
resolve_main_config

umask 077
EVIDENCE_DIR="$(mktemp -d "/root/nginx-413-${CASE_ID}-${PHASE}.XXXXXXXX")"
chmod 700 "$EVIDENCE_DIR"
[[ "$(stat -Lc '%u:%a' -- "$EVIDENCE_DIR")" == '0:700' ]] || die 'Evidence directory is not root-owned mode 700'
COMPLETE=0
trap 'if (( COMPLETE == 0 )); then echo "ERROR: incomplete evidence remains private at ${EVIDENCE_DIR:-unknown}; no state was published" >&2; fi' EXIT

UPLOAD_SNAPSHOT="${EVIDENCE_DIR}/request.bin"
AUTH_SNAPSHOT="${EVIDENCE_DIR}/curl-auth.conf"
SCRIPT_SNAPSHOT="${EVIDENCE_DIR}/replay-script.snapshot"
copy_frozen "$UPLOAD_FILE" "$UPLOAD_SNAPSHOT"
copy_frozen "$AUTH_CONFIG" "$AUTH_SNAPSHOT"
copy_frozen "$SCRIPT_SOURCE" "$SCRIPT_SNAPSHOT"
[[ "$(hash_file "$SCRIPT_SNAPSHOT")" == "$SCRIPT_SOURCE_HASH" ]] || die 'Saved script changed while snapshotting'
UPLOAD_HASH="$(hash_file "$UPLOAD_SNAPSHOT")"
AUTH_HASH="$(hash_file "$AUTH_SNAPSHOT")"
SCRIPT_HASH="$(hash_file "$SCRIPT_SNAPSHOT")"

EXEC_FILE="${EVIDENCE_DIR}/execstart.json"
TEST_ARGV_FILE="${EVIDENCE_DIR}/nginx-test.argv.nul"
printf '%s\n' "$EXEC_JSON" | jq -cS . >"$EXEC_FILE"
printf '%s\0' "$NGINX_BIN" -t "${NGINX_CONTEXT[@]}" >"$TEST_ARGV_FILE"
chmod 600 "$EXEC_FILE" "$TEST_ARGV_FILE"
EXEC_HASH="$(hash_file "$EXEC_FILE")"
TEST_ARGV_HASH="$(hash_file "$TEST_ARGV_FILE")"
TEST_ARGV_B64="$(base64 -w0 -- "$TEST_ARGV_FILE")"

DUMP="${EVIDENCE_DIR}/nginx-T.txt"
INVENTORY="${EVIDENCE_DIR}/nginx-inventory.txt"
HEADERS="${EVIDENCE_DIR}/response.headers"
BODY="${EVIDENCE_DIR}/response.body"
STATUS_FILE="${EVIDENCE_DIR}/response.status"
JOURNAL="${EVIDENCE_DIR}/nginx.journal"
"$NGINX_BIN" -T "${NGINX_CONTEXT[@]}" >"$DUMP" || die 'Exact structured-context nginx -T failed; no request was sent'
awk '
  /^# configuration file / { source=$0 }
  /^[[:space:]]*(server[[:space:]]*\{|location[[:space:]]|listen[[:space:]]|error_log[[:space:]]|access_log[[:space:]]|fastcgi_pass[[:space:]])/ {
    print source; printf "%d:%s\n", NR, $0
  }
' "$DUMP" >"$INVENTORY"
[[ -s "$INVENTORY" ]] || die 'The Nginx inventory is empty'
chmod 600 "$DUMP" "$INVENTORY"
sed -n '1,240p' "$INVENTORY"
echo 'Human check required: map hostname and request path to one server/location; this inventory does not resolve precedence.' >&2
echo "WARNING: ${EVIDENCE_DIR} contains auth, headers, body, config, and logs. These can expose secrets, addresses, and request IDs; keep it private and redact any copy." >&2

START_TIME="$(date --iso-8601=ns)"
HTTP_CODE="$(curl --disable --silent --show-error \
  --config "$AUTH_SNAPSHOT" \
  --request "$METHOD" \
  --url "$URL" \
  --form "${FIELD}=@${UPLOAD_SNAPSHOT};filename=${UPLOAD_NAME};type=application/octet-stream" \
  --dump-header "$HEADERS" \
  --output "$BODY" \
  --write-out '%{http_code}')" || die 'curl failed; no state was published'
END_TIME="$(date --iso-8601=ns)"
[[ "$HTTP_CODE" =~ ^[0-9]{3}$ ]] || die 'curl did not return an HTTP status'
[[ "$(hash_file "$UPLOAD_SNAPSHOT")" == "$UPLOAD_HASH" && "$(hash_file "$AUTH_SNAPSHOT")" == "$AUTH_HASH" ]] || die 'Frozen request or auth bytes changed during curl'
printf '%s\n' "$HTTP_CODE" >"$STATUS_FILE"
chmod 600 "$HEADERS" "$BODY" "$STATUS_FILE"

journalctl --unit="$UNIT" --since="$START_TIME" --until="$END_TIME" --no-pager >"$JOURNAL" || die 'Could not capture the exact-unit journal window'
chmod 600 "$JOURNAL"
if [[ "$ERROR_LOG" != 'NONE' ]]; then tail -n 200 -- "$ERROR_LOG" >"${EVIDENCE_DIR}/error-log.tail"; chmod 600 "${EVIDENCE_DIR}/error-log.tail"; fi
if [[ "$ACCESS_LOG" != 'NONE' ]]; then tail -n 200 -- "$ACCESS_LOG" >"${EVIDENCE_DIR}/access-log.tail"; chmod 600 "${EVIDENCE_DIR}/access-log.tail"; fi
[[ "$(hash_file "$UPLOAD_SNAPSHOT")" == "$UPLOAD_HASH" && "$(hash_file "$AUTH_SNAPSHOT")" == "$AUTH_HASH" && "$(hash_file "$SCRIPT_SNAPSHOT")" == "$SCRIPT_HASH" ]] || die 'A frozen identity artifact changed after the request'

ARTIFACT_HASHES='{}'
for ARTIFACT in "$EVIDENCE_DIR"/*; do
  [[ -f "$ARTIFACT" && ! -L "$ARTIFACT" && "$(stat -Lc '%u:%a' -- "$ARTIFACT")" == '0:600' ]] || die "Unsafe evidence artifact: $ARTIFACT"
  NAME="$(basename -- "$ARTIFACT")"
  VALUE="$(hash_file "$ARTIFACT")"
  ARTIFACT_HASHES="$(jq -c --arg name "$NAME" --arg value "$VALUE" '. + {($name): $value}' <<<"$ARTIFACT_HASHES")"
done

MANIFEST="${EVIDENCE_DIR}/manifest.json"
MANIFEST_TMP="$(mktemp --tmpdir="$EVIDENCE_DIR" .manifest.XXXXXXXX)"
jq -nS \
  --arg case_id "$CASE_ID" --arg phase "$PHASE" --arg method "$METHOD" --arg url "$URL" --arg field "$FIELD" \
  --arg request_sha256 "$UPLOAD_HASH" --arg auth_sha256 "$AUTH_HASH" --arg script_sha256 "$SCRIPT_HASH" \
  --arg unit "$UNIT" --arg executable "$NGINX_BIN" --arg execstart_sha256 "$EXEC_HASH" \
  --arg test_argv_sha256 "$TEST_ARGV_HASH" --arg test_argv_b64 "$TEST_ARGV_B64" --arg main_config "$NGINX_CONF" \
  --arg started_at "$START_TIME" --arg ended_at "$END_TIME" --arg http_status "$HTTP_CODE" \
  --argjson artifacts "$ARTIFACT_HASHES" '
  {
    identity: {
      case_id: $case_id, method: $method, url: $url, multipart_field: $field,
      request_sha256: $request_sha256, auth_sha256: $auth_sha256, script_sha256: $script_sha256,
      nginx_unit: $unit, nginx_executable: $executable, execstart_sha256: $execstart_sha256,
      test_argv_sha256: $test_argv_sha256, test_argv_b64: $test_argv_b64, main_config: $main_config
    },
    phase: $phase, started_at: $started_at, ended_at: $ended_at,
    http_status: $http_status, artifacts: $artifacts
  }
' >"$MANIFEST_TMP"
chown root:root "$MANIFEST_TMP"; chmod 600 "$MANIFEST_TMP"
mv -T -- "$MANIFEST_TMP" "$MANIFEST"
MANIFEST_HASH="$(hash_file "$MANIFEST")"
IDENTITY_HASH="$(jq -cS '.identity' "$MANIFEST" | sha256sum | awk '{print $1}')"

STATE_TMP="$(mktemp --tmpdir=/root ".nginx-413-${CASE_ID}-${PHASE}.state.XXXXXXXX")"
jq -nS --arg case_id "$CASE_ID" --arg phase "$PHASE" --arg evidence "$EVIDENCE_DIR" \
  --arg manifest "$MANIFEST" --arg manifest_sha256 "$MANIFEST_HASH" --arg identity_sha256 "$IDENTITY_HASH" \
  '{case_id:$case_id,phase:$phase,evidence_dir:$evidence,manifest:$manifest,manifest_sha256:$manifest_sha256,identity_sha256:$identity_sha256}' >"$STATE_TMP"
chown root:root "$STATE_TMP"; chmod 600 "$STATE_TMP"
mv -T -- "$STATE_TMP" "$STATE_FILE"
COMPLETE=1
trap - EXIT
printf 'phase=%s status=%s state=%s evidence=%s manifest_sha256=%s\n' "$PHASE" "$HTTP_CODE" "$STATE_FILE" "$EVIDENCE_DIR" "$MANIFEST_HASH"

2. Encuentra la configuración efectiva de Nginx

No asumas que un archivo en /etc/nginx/sites-available está incluido. La documentación de los parámetros de Nginx indica que -t comprueba sintaxis y archivos referenciados, mientras -T hace lo mismo y además vuelca la configuración. Busca el server_name correcto, sigue los include, confirma qué location gana para la ruta y anota todas las apariciones de client_max_body_size. Una directiva más específica puede heredar o reemplazar el valor que viste en otro bloque.

Según la documentación oficial de client_max_body_size, el valor predeterminado es 1m, los contextos válidos son http, server y location, y superar el valor produce 413. El valor 0 desactiva la comprobación: no lo uses como arreglo general.

3. Elige un límite coherente y de alcance estrecho

Parte del mayor archivo o cuerpo legítimo que el endpoint debe aceptar y añade un margen pequeño para el envoltorio de la solicitud. No copies una cifra universal. Un límite mayor incrementa la superficie de abuso, el tiempo de conexión y la presión sobre memoria, CPU y almacenamiento temporal; comprueba capacidad y controles de autenticación o rate limiting por separado.

Si todo un sitio necesita el mismo umbral, colócalo en su bloque server. Si solo una ruta de importación lo necesita y ya existe un location correcto para ella, usa ese alcance. No crees a ciegas un location nuevo: podría cambiar el enrutamiento, FastCGI o proxy. Este extracto muestra 64m únicamente como ejemplo adaptable dentro del server existente:

nginx
server {
    # Conserva sin cambios listen, TLS, rutas y upstreams existentes.
    server_name example.com;
    # TERAMONT-NGINX-413-LIMIT
    client_max_body_size 64m;
}

4. Respalda, edita, prueba y recarga Nginx

Usa como CONFIG el archivo regular con header exacto en nginx -T. Pausa Ansible, el panel o el deployer que gestione ese grafo. La transacción verifica el inode del lock, registra hashes del vector systemd y los includes, crea backup/candidato en el mismo filesystem y aborta ante cambios. El gate normaliza solo espacio inicial y exige una pareja marcador/directiva única bajo el header; muestra su server/location para confirmación humana. Antes del reload vuelve a comprobar lock, binario, vector, archivo y grafo. Eso prueba identidad e inclusión, no herencia ni precedencia.

set -euo pipefail

die() { echo "ERROR: $*" >&2; exit 1; }
critical() {
  echo "CRITICAL non-convergent: $*" >&2
  echo "Rescue paths: CONFIG=${CONFIG:-unknown} TX_DIR=${TX_DIR:-unknown} BACKUP=${BACKUP:-unknown} STATE=${STATE:-unknown}" >&2
  exit 1
}
hash_file() { sha256sum -- "$1" | awk '{print $1}'; }
[[ "$EUID" -eq 0 ]] || die 'Run the reviewed script as root'
for COMMAND in busctl flock jq sha256sum systemctl; do command -v "$COMMAND" >/dev/null || die "Missing command: $COMMAND"; done

UNIT='nginx.service'
CONFIG='/REPLACE/exact/included/server.conf'
EXPECTED_VALUE='64m'
MARKER='# TERAMONT-NGINX-413-LIMIT'
DIRECTIVE="client_max_body_size ${EXPECTED_VALUE};"
LOCK_DIR='/run/teramont-nginx-413'
LOCKFILE="${LOCK_DIR}/transaction.lock"
CONFIG_MANAGER_STATE='REPLACE_PAUSED_OR_NONE'

verify_lock_dir() {
  [[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" ]] || return 1
  [[ "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || return 1
  [[ -n "${LOCK_DIR_ID:-}" && "$(stat -Lc '%d:%i' -- "$LOCK_DIR")" == "$LOCK_DIR_ID" ]]
}
verify_lock() {
  local FD_PATH="/proc/$$/fd/${LOCK_FD}" FD_ID PATH_ID FD_META PATH_META
  verify_lock_dir || return 1
  [[ -f "$FD_PATH" && -f "$LOCKFILE" && ! -L "$LOCKFILE" ]] || return 1
  FD_ID="$(stat -Lc '%d:%i' -- "$FD_PATH")" || return 1
  PATH_ID="$(stat -Lc '%d:%i' -- "$LOCKFILE")" || return 1
  FD_META="$(stat -Lc '%u:%a:%h' -- "$FD_PATH")" || return 1
  PATH_META="$(stat -Lc '%u:%a:%h' -- "$LOCKFILE")" || return 1
  [[ "$FD_ID" == "$PATH_ID" && "$FD_META" == '0:600:1' && "$PATH_META" == "$FD_META" ]]
}
prepare_lock_dir() {
  umask 077
  if [[ -e "$LOCK_DIR" || -L "$LOCK_DIR" ]]; then
    [[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" && "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || die 'Unsafe dedicated lock directory'
  else
    mkdir --mode=700 -- "$LOCK_DIR" || die 'Cannot create the dedicated lock directory'
  fi
  [[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" && "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || die 'Dedicated lock directory failed validation'
  LOCK_DIR_ID="$(stat -Lc '%d:%i' -- "$LOCK_DIR")" || die 'Cannot identify the dedicated lock directory'
  verify_lock_dir || die 'Dedicated lock directory identity changed'
}
acquire_lock() {
  local FD_PATH FD_ID PATH_ID FD_META PATH_META
  prepare_lock_dir
  if [[ -e "$LOCKFILE" || -L "$LOCKFILE" ]]; then
    [[ -f "$LOCKFILE" && ! -L "$LOCKFILE" ]] || die 'Unsafe cooperative lock path'
    [[ "$(stat -Lc '%u:%h' -- "$LOCKFILE")" == '0:1' ]] || die 'Existing lock must be root-owned with one link'
  fi
  exec {LOCK_FD}>>"$LOCKFILE" || die 'Cannot open the cooperative lock without truncation'
  FD_PATH="/proc/$$/fd/${LOCK_FD}"
  [[ -f "$FD_PATH" && -f "$LOCKFILE" && ! -L "$LOCKFILE" ]] || die 'Lock type changed during open'
  FD_ID="$(stat -Lc '%d:%i' -- "$FD_PATH")"; PATH_ID="$(stat -Lc '%d:%i' -- "$LOCKFILE")"
  FD_META="$(stat -Lc '%u:%h' -- "$FD_PATH")"; PATH_META="$(stat -Lc '%u:%h' -- "$LOCKFILE")"
  [[ "$FD_ID" == "$PATH_ID" && "$FD_META" == '0:1' && "$PATH_META" == "$FD_META" ]] || die 'Lock FD/path identity, owner, or link count is unsafe before chmod'
  chmod 600 -- "$FD_PATH" || die 'Cannot set lock mode through its open descriptor'
  verify_lock || die 'Lock FD/path identity is unsafe after descriptor chmod'
  flock --exclusive --nonblock "$LOCK_FD" || die 'Another cooperating Nginx transaction holds the lock'
  verify_lock || die 'Lock FD/path identity changed after flock'
}
lock_self_test() {
  local TEST_ROOT TEST_DIR TEST_LOCK VICTIM BEFORE_HASH BEFORE_MODE AFTER_HASH AFTER_MODE
  TEST_ROOT="$(mktemp -d /tmp/teramont-nginx-lock-test.XXXXXXXX)" || die 'Cannot create lock self-test directory'
  chmod 700 -- "$TEST_ROOT"; TEST_DIR="${TEST_ROOT}/lockdir"; TEST_LOCK="${TEST_DIR}/transaction.lock"; VICTIM="${TEST_ROOT}/victim"
  mkdir --mode=700 -- "$TEST_DIR"; printf 'DO-NOT-CHANGE\n' >"$VICTIM"; chmod 640 -- "$VICTIM"
  BEFORE_HASH="$(hash_file "$VICTIM")"; BEFORE_MODE="$(stat -Lc '%a' -- "$VICTIM")"; ln -s -- "$VICTIM" "$TEST_LOCK"
  if (LOCK_DIR="$TEST_DIR"; LOCKFILE="$TEST_LOCK"; unset LOCK_DIR_ID LOCK_FD; acquire_lock) >/dev/null 2>&1; then
    die 'LOCK_SELF_TEST failed: a symlink lock was accepted'
  fi
  AFTER_HASH="$(hash_file "$VICTIM")"; AFTER_MODE="$(stat -Lc '%a' -- "$VICTIM")"
  [[ "$AFTER_HASH" == "$BEFORE_HASH" && "$AFTER_MODE" == "$BEFORE_MODE" ]] || die 'LOCK_SELF_TEST failed: victim bytes or mode changed'
  unlink -- "$TEST_LOCK"; unlink -- "$VICTIM"; rmdir -- "$TEST_DIR" "$TEST_ROOT"
  printf 'LOCK_SELF_TEST=PASS script_aborted=1 victim_hash_before=%s victim_hash_after=%s victim_mode_before=%s victim_mode_after=%s\n' "$BEFORE_HASH" "$AFTER_HASH" "$BEFORE_MODE" "$AFTER_MODE"
}

if [[ "${1:-}" == '--lock-self-test' ]]; then lock_self_test; exit 0; fi

[[ "$CONFIG" == /* && "$CONFIG" != /REPLACE* ]] || die 'Replace CONFIG with the exact file header shown by nginx -T'
[[ "$EXPECTED_VALUE" =~ ^[1-9][0-9]*[kKmMgG]?$ ]] || die 'EXPECTED_VALUE must be finite and nonzero'
case "$CONFIG_MANAGER_STATE" in PAUSED|NONE_CONFIRMED) ;; *) die 'Pause the config manager/deployer, or set NONE_CONFIRMED after proving none controls this graph' ;; esac
[[ -f "$CONFIG" && ! -L "$CONFIG" ]] || die 'CONFIG must be a regular, non-symlink file'
CONFIG="$(readlink -f -- "$CONFIG")"

get_unit_path() {
  local REPLY
  REPLY="$(busctl --json=short call org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager GetUnit s "$UNIT")" || die 'D-Bus GetUnit failed'
  UNIT_PATH="$(jq -er 'select(.type=="o") | if (.data|type)=="string" then .data elif ((.data|type)=="array" and (.data|length)==1 and (.data[0]|type)=="string") then .data[0] else error("ambiguous") end' <<<"$REPLY")" || die 'Cannot decode the unit path; stop and consult the administrator'
}
capture_nginx_identity() {
  local EXEC_PATH ARGV0_BIN PROC_BIN
  get_unit_path
  EXEC_JSON="$(busctl --json=short get-property org.freedesktop.systemd1 "$UNIT_PATH" org.freedesktop.systemd1.Service ExecStart)" || die 'Cannot read structured ExecStart'
  jq -e '.type=="a(sasbttttuii)" and (.data|type)=="array" and (.data|length)==1 and (.data[0]|length)==10 and (.data[0][0]|type)=="string" and (.data[0][1]|type)=="array" and (.data[0][1]|length)>=1 and all(.data[0][1][];type=="string")' <<<"$EXEC_JSON" >/dev/null || die 'ExecStart is not one unambiguous structured command; stop and consult the administrator'
  mapfile -d '' -t EXEC_ARGV < <(jq -j '.data[0][1][] | ., "\u0000"' <<<"$EXEC_JSON")
  EXEC_PATH="$(jq -er '.data[0][0]' <<<"$EXEC_JSON")"
  [[ "$EXEC_PATH" == /* && "${EXEC_ARGV[0]}" == /* ]] || die 'ExecStart binary paths must be absolute'
  NGINX_BIN="$(readlink -f -- "$EXEC_PATH")"
  ARGV0_BIN="$(readlink -f -- "${EXEC_ARGV[0]}")"
  MAINPID="$(systemctl show --property=MainPID --value "$UNIT")"
  [[ "$MAINPID" =~ ^[1-9][0-9]*$ && -e "/proc/${MAINPID}/exe" ]] || die 'The exact Nginx unit has no readable MainPID'
  PROC_BIN="$(readlink -f -- "/proc/${MAINPID}/exe")"
  [[ -x "$NGINX_BIN" && "$NGINX_BIN" == "$ARGV0_BIN" && "$NGINX_BIN" == "$PROC_BIN" ]] || die 'Structured ExecStart, argv[0], and /proc/MainPID/exe disagree'
  NGINX_CONTEXT=("${EXEC_ARGV[@]:1}")
  for ARG in "${NGINX_CONTEXT[@]}"; do case "$ARG" in -s|-s*|-t|-T|-q|-v|-V|-h|-\?) die 'ExecStart contains an action flag unsafe to reuse' ;; esac; done
  EXEC_CANON="$(jq -cS . <<<"$EXEC_JSON")"
  EXEC_HASH="$(printf '%s\n' "$EXEC_CANON" | sha256sum | awk '{print $1}')"
  ARGV_HASH="$({ printf '%s\0' "${EXEC_ARGV[@]}"; } | sha256sum | awk '{print $1}')"
}
resolve_main_config() {
  local CONF='' PREFIX='' CONF_COUNT=0 PREFIX_COUNT=0 I VERSION COMPILED_CONF COMPILED_PREFIX
  for ((I=0; I<${#NGINX_CONTEXT[@]}; I++)); do
    case "${NGINX_CONTEXT[I]}" in
      -c) ((I+1<${#NGINX_CONTEXT[@]})) || die 'Structured -c lacks a value'; CONF="${NGINX_CONTEXT[I+1]}"; ((CONF_COUNT+=1)); ((I+=1)) ;;
      -c*) CONF="${NGINX_CONTEXT[I]#-c}"; ((CONF_COUNT+=1)) ;;
      -p) ((I+1<${#NGINX_CONTEXT[@]})) || die 'Structured -p lacks a value'; PREFIX="${NGINX_CONTEXT[I+1]}"; ((PREFIX_COUNT+=1)); ((I+=1)) ;;
      -p*) PREFIX="${NGINX_CONTEXT[I]#-p}"; ((PREFIX_COUNT+=1)) ;;
    esac
  done
  (( CONF_COUNT<=1 && PREFIX_COUNT<=1 )) || die 'Structured -c/-p is ambiguous'
  VERSION="$("$NGINX_BIN" -V 2>&1)" || die 'nginx -V failed'
  COMPILED_CONF="$(grep -oE -- '--conf-path=[^[:space:]]+' <<<"$VERSION" | sed 's/^--conf-path=//' | awk 'NR==1{v=$0} END{if(NR==1) print v}')"
  COMPILED_PREFIX="$(grep -oE -- '--prefix=[^[:space:]]+' <<<"$VERSION" | sed 's/^--prefix=//' | awk 'NR==1{v=$0} END{if(NR==1) print v}')"
  [[ -n "$COMPILED_CONF" ]] || die 'Compiled --conf-path is missing or ambiguous'
  [[ -n "$PREFIX" ]] || PREFIX="$COMPILED_PREFIX"
  [[ -n "$CONF" ]] || CONF="$COMPILED_CONF"
  if [[ "$CONF" != /* ]]; then [[ "$PREFIX" == /* ]] || die 'Relative config has no absolute prefix'; CONF="${PREFIX%/}/$CONF"; fi
  [[ -f "$CONF" && ! -L "$CONF" ]] || die 'Resolved main config is not a regular file'
  NGINX_CONF="$(readlink -f -- "$CONF")"
}
run_nginx_test() { "$NGINX_BIN" -t "${NGINX_CONTEXT[@]}"; }
make_graph_snapshot() {
  local PREFIX="$1" DUMP="${1}.dump" PATHS="${1}.paths" GRAPH="${1}.graph.tsv" FILE
  "$NGINX_BIN" -T "${NGINX_CONTEXT[@]}" >"$DUMP" || return 1
  awk '/^# configuration file / {line=$0; sub(/^# configuration file /,"",line); sub(/:$/,"",line); print line}' "$DUMP" | LC_ALL=C sort -u >"$PATHS"
  [[ -s "$PATHS" ]] || return 1
  : >"$GRAPH"
  while IFS= read -r FILE; do
    [[ "$FILE" == /* && "$FILE" != *$'\t'* && -f "$FILE" && ! -L "$FILE" ]] || return 1
    printf '%s\t%s\n' "$FILE" "$(hash_file "$FILE")" >>"$GRAPH"
  done <"$PATHS"
  chmod 600 "$DUMP" "$PATHS" "$GRAPH"
}
systemctl is-active --quiet "$UNIT" || die 'The exact Nginx unit is not active'
capture_nginx_identity
resolve_main_config
START_EXEC_HASH="$EXEC_HASH"; START_ARGV_HASH="$ARGV_HASH"; START_BIN="$NGINX_BIN"; START_CONF="$NGINX_CONF"
START_ARGV_B64="$({ printf '%s\0' "${EXEC_ARGV[@]}"; } | base64 -w0)"
TEST_ARGV_B64="$({ printf '%s\0' "$NGINX_BIN" -t "${NGINX_CONTEXT[@]}"; } | base64 -w0)"
run_nginx_test || die 'Starting configuration failed with the structured ExecStart context; no edit was made'

acquire_lock

BASE_HASH="$(hash_file "$CONFIG")"
BASE_UID="$(stat -Lc '%u' "$CONFIG")"; BASE_GID="$(stat -Lc '%g' "$CONFIG")"; BASE_MODE="$(stat -Lc '%a' "$CONFIG")"
[[ "$BASE_UID" == 0 ]] || die 'CONFIG and transaction artifacts must be root-owned'
CONFIG_DIR="$(dirname -- "$CONFIG")"
TX_DIR="$(mktemp -d --tmpdir="$CONFIG_DIR" .nginx-413.XXXXXXXX)"
chmod 700 "$TX_DIR"
[[ "$(stat -Lc '%d:%u:%a' "$TX_DIR")" == "$(stat -Lc '%d' "$CONFIG_DIR"):0:700" ]] || critical 'Transaction directory is not protected on CONFIG filesystem'
BACKUP="${TX_DIR}/config.before"; BACKUP_SHA="${TX_DIR}/config.before.sha256"; CANDIDATE="${TX_DIR}/config.candidate"; STATE="${TX_DIR}/transaction.json"
cp --archive -- "$CONFIG" "$BACKUP"; cp --archive -- "$CONFIG" "$CANDIDATE"
[[ "$(hash_file "$BACKUP")" == "$BASE_HASH" ]] || critical 'Backup hash differs from base'
printf '%s  %s\n' "$BASE_HASH" "$BACKUP" >"$BACKUP_SHA"; chmod 600 "$BACKUP_SHA"; sha256sum --check "$BACKUP_SHA" || critical 'Persistent backup SHA failed'
make_graph_snapshot "${TX_DIR}/base" || critical 'Could not freeze the starting include graph'
BASE_GRAPH_HASH="$(hash_file "${TX_DIR}/base.graph.tsv")"
grep -Fqx "${CONFIG}" "${TX_DIR}/base.paths" || critical 'CONFIG is not an exact member of the effective include graph'

echo "Edit only this candidate: $CANDIDATE" >&2
echo 'Insert these adjacent lines with any consistent leading indentation:' >&2
printf '    %s\n    %s\n' "$MARKER" "$DIRECTIVE" >&2
[[ -x /usr/bin/vi ]] || die '/usr/bin/vi is unavailable; stop without editing CONFIG'
/usr/bin/vi "$CANDIDATE"
[[ -f "$CANDIDATE" && ! -L "$CANDIDATE" ]] || die 'Candidate changed type'
chown "$BASE_UID:$BASE_GID" "$CANDIDATE"; chmod "$BASE_MODE" "$CANDIDATE"
MARK_COUNT="$(awk -v m="$MARKER" '{v=$0; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); if(v==m)c++} END{print c+0}' "$CANDIDATE")"
PAIR_COUNT="$(awk -v m="$MARKER" -v d="$DIRECTIVE" '{v=$0; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); if(prev==m && v==d)c++; prev=v} END{print c+0}' "$CANDIDATE")"
DIRECTIVE_COUNT="$(awk -v d="$DIRECTIVE" '{v=$0; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); if(v==d)c++} END{print c+0}' "$CANDIDATE")"
[[ "$MARK_COUNT" == 1 && "$PAIR_COUNT" == 1 && "$DIRECTIVE_COUNT" == 1 ]] || die 'Candidate must contain one normalized adjacent marker/directive pair'
DIFF_RC=0; diff -u -- "$BACKUP" "$CANDIDATE" || DIFF_RC=$?; [[ "$DIFF_RC" == 1 ]] || die 'Candidate is unchanged or diff failed'
echo 'Trace braces, hostname, request path, includes, and duplicate directives; this byte gate does not resolve precedence.' >&2
read -r -p 'Type APPLY-NGINX-413 after reviewing the entire diff: ' CONFIRM
[[ "$CONFIRM" == 'APPLY-NGINX-413' ]] || die 'Cancelled before installing candidate'

CANDIDATE_HASH="$(hash_file "$CANDIDATE")"
verify_lock || critical 'Lock identity changed before installation'
[[ "$(hash_file "$CONFIG")" == "$BASE_HASH" && "$(hash_file "${TX_DIR}/base.graph.tsv")" == "$BASE_GRAPH_HASH" ]] || critical 'CONFIG or frozen base graph changed before installation'
make_graph_snapshot "${TX_DIR}/base-recheck" || critical 'Could not recheck base graph before installation'
cmp -s "${TX_DIR}/base.graph.tsv" "${TX_DIR}/base-recheck.graph.tsv" || critical 'An include changed outside the cooperative lock; candidate was not installed'
unexpected_after_mutation() { local RC=$?; trap - ERR; critical "unexpected command failure after mutation (rc=${RC}, line=${BASH_LINENO[0]:-unknown}); state was not assumed"; }
trap unexpected_after_mutation ERR
mv -T -- "$CANDIDATE" "$CONFIG" || critical 'Atomic candidate rename failed'
[[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" && "$(stat -Lc '%u:%g:%a' "$CONFIG")" == "${BASE_UID}:${BASE_GID}:${BASE_MODE}" ]] || critical 'Installed candidate hash or attributes mismatch'

recover_base_after_failure() {
  local REASON="$1" CURRENT_COPY="${TX_DIR}/candidate.rescue" STAGE="${TX_DIR}/base.restore.stage"
  [[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || critical "$REASON; active bytes changed, so no automatic overwrite was attempted"
  cp --archive -- "$CONFIG" "$CURRENT_COPY" || critical "$REASON; could not preserve current candidate bytes"
  [[ "$(hash_file "$CURRENT_COPY")" == "$CANDIDATE_HASH" ]] || critical "$REASON; current rescue hash mismatch"
  cp --archive -- "$BACKUP" "$STAGE" || critical "$REASON; could not stage base backup"
  chown "$BASE_UID:$BASE_GID" "$STAGE"; chmod "$BASE_MODE" "$STAGE"
  [[ "$(hash_file "$STAGE")" == "$BASE_HASH" && "$(stat -Lc '%d' "$STAGE")" == "$(stat -Lc '%d' "$CONFIG")" ]] || critical "$REASON; base stage hash/filesystem mismatch"
  mv -T -- "$STAGE" "$CONFIG" || critical "$REASON; atomic base rename failed"
  [[ "$(hash_file "$CONFIG")" == "$BASE_HASH" ]] || critical "$REASON; base hash is not on disk after rename"
  if ! run_nginx_test; then
    STAGE="${TX_DIR}/candidate.restore.stage"
    cp --archive -- "$CURRENT_COPY" "$STAGE" || critical "$REASON; base test failed and current bytes could not be staged"
    chown "$BASE_UID:$BASE_GID" "$STAGE"; chmod "$BASE_MODE" "$STAGE"
    mv -T -- "$STAGE" "$CONFIG" || critical "$REASON; base test failed and current rename failed"
    [[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || critical "$REASON; current hash was not recovered"
    run_nginx_test || critical "$REASON; base failed and repeated current test also failed; no reload occurred"
    critical "$REASON; base test failed, current hash/tests are recorded, and no reload occurred"
  fi
  critical "$REASON; no reload occurred; inspect the recorded disk hash and rescue paths before any next action"
}

[[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || critical 'Candidate changed immediately before its test'
run_nginx_test || recover_base_after_failure 'Candidate nginx -t failed'
[[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || critical 'Candidate changed after nginx -t'
make_graph_snapshot "${TX_DIR}/candidate" || recover_base_after_failure 'Candidate nginx -T failed'
awk -F '\t' -v cfg="$CONFIG" -v h="$CANDIDATE_HASH" 'BEGIN{OFS="\t"} $1==cfg{$2=h} {print}' "${TX_DIR}/base.graph.tsv" >"${TX_DIR}/candidate.expected.graph.tsv"
chmod 600 "${TX_DIR}/candidate.expected.graph.tsv"
cmp -s "${TX_DIR}/candidate.expected.graph.tsv" "${TX_DIR}/candidate.graph.tsv" || recover_base_after_failure 'Effective graph changed beyond the reviewed CONFIG bytes'
CANDIDATE_GRAPH_HASH="$(hash_file "${TX_DIR}/candidate.graph.tsv")"

HEADER="# configuration file ${CONFIG}:"
SECTION="${TX_DIR}/config.section"
awk -v h="$HEADER" '$0==h{seen++;active=1;next} /^# configuration file /&&active{active=0} active{print} END{if(seen!=1)exit 42}' "${TX_DIR}/candidate.dump" >"$SECTION" || recover_base_after_failure 'CONFIG header is missing or ambiguous in nginx -T'
GLOBAL_MARKERS="$(awk -v m="$MARKER" '{v=$0;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);if(v==m)c++}END{print c+0}' "${TX_DIR}/candidate.dump")"
SECTION_MARKERS="$(awk -v m="$MARKER" '{v=$0;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);if(v==m)c++}END{print c+0}' "$SECTION")"
SECTION_PAIRS="$(awk -v m="$MARKER" -v d="$DIRECTIVE" '{v=$0;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);if(prev==m&&v==d)c++;prev=v}END{print c+0}' "$SECTION")"
[[ "$GLOBAL_MARKERS" == 1 && "$SECTION_MARKERS" == 1 && "$SECTION_PAIRS" == 1 ]] || recover_base_after_failure 'Normalized marker/directive pair is not globally unique and exact under CONFIG header'
awk -v m="$MARKER" '{v=$0;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v)} /^[[:space:]]*server[[:space:]]*\{/ {server=$0;location=""} /^[[:space:]]*location[[:space:]]/ {location=$0} v==m {print "Server seen: " server; print "Location seen: " (location==""?"(server scope; confirm braces)":location); print "Marker line: " NR}' "$SECTION"
echo 'The exact pair proves inclusion, not inheritance or location precedence.' >&2
read -r -p 'Type CONFIRM-EFFECTIVE-CONTEXT after tracing the actual server/location: ' CONFIRM
[[ "$CONFIRM" == 'CONFIRM-EFFECTIVE-CONTEXT' ]] || recover_base_after_failure 'Human context confirmation was not given'

EXEC_FILE="${TX_DIR}/execstart.json"; ARGV_FILE="${TX_DIR}/execstart.argv.nul"
printf '%s\n' "$EXEC_CANON" >"$EXEC_FILE"; printf '%s\0' "${EXEC_ARGV[@]}" >"$ARGV_FILE"; chmod 600 "$EXEC_FILE" "$ARGV_FILE"
jq -nS --arg unit "$UNIT" --arg config "$CONFIG" --arg main_config "$NGINX_CONF" --arg nginx_bin "$NGINX_BIN" \
  --arg exec_hash "$START_EXEC_HASH" --arg argv_hash "$START_ARGV_HASH" --arg argv_b64 "$START_ARGV_B64" --arg test_argv_b64 "$TEST_ARGV_B64" \
  --arg base_hash "$BASE_HASH" --arg candidate_hash "$CANDIDATE_HASH" --arg base_graph_hash "$BASE_GRAPH_HASH" --arg candidate_graph_hash "$CANDIDATE_GRAPH_HASH" \
  --arg backup "$BACKUP" --arg backup_sha "$BACKUP_SHA" --arg lockdir "$LOCK_DIR" --arg lockfile "$LOCKFILE" --arg manager_state "$CONFIG_MANAGER_STATE" \
  --argjson uid "$BASE_UID" --argjson gid "$BASE_GID" --arg mode "$BASE_MODE" \
  '{unit:$unit,config:$config,main_config:$main_config,nginx_bin:$nginx_bin,execstart_sha256:$exec_hash,argv_sha256:$argv_hash,argv_b64:$argv_b64,test_argv_b64:$test_argv_b64,base_hash:$base_hash,candidate_hash:$candidate_hash,base_graph_hash:$base_graph_hash,candidate_graph_hash:$candidate_graph_hash,backup:$backup,backup_sha:$backup_sha,lock_dir:$lockdir,lockfile:$lockfile,config_manager_state:$manager_state,base_uid:$uid,base_gid:$gid,base_mode:$mode}' >"${STATE}.tmp"
chmod 600 "${STATE}.tmp"; mv -T -- "${STATE}.tmp" "$STATE"

verify_lock || critical 'Lock identity changed immediately before reload'
[[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || critical 'Candidate changed immediately before reload'
capture_nginx_identity; resolve_main_config
[[ "$EXEC_HASH" == "$START_EXEC_HASH" && "$ARGV_HASH" == "$START_ARGV_HASH" && "$NGINX_BIN" == "$START_BIN" && "$NGINX_CONF" == "$START_CONF" ]] || critical 'Unit binary, structured vector, or main config changed before reload'
make_graph_snapshot "${TX_DIR}/candidate-recheck" || critical 'Final graph recheck failed; no reload occurred'
cmp -s "${TX_DIR}/candidate.graph.tsv" "${TX_DIR}/candidate-recheck.graph.tsv" || critical 'An include changed immediately before reload; no reload occurred'
run_nginx_test || recover_base_after_failure 'Final candidate test failed'
verify_lock || critical 'Lock identity changed after the final test; no reload occurred'
[[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || critical 'Candidate changed after the final test; no reload occurred'
capture_nginx_identity; resolve_main_config
[[ "$EXEC_HASH" == "$START_EXEC_HASH" && "$ARGV_HASH" == "$START_ARGV_HASH" && "$NGINX_BIN" == "$START_BIN" && "$NGINX_CONF" == "$START_CONF" ]] || critical 'Unit identity changed after the final test; no reload occurred'
make_graph_snapshot "${TX_DIR}/candidate-reload-gate" || critical 'Reload-gate graph capture failed; no reload occurred'
cmp -s "${TX_DIR}/candidate.graph.tsv" "${TX_DIR}/candidate-reload-gate.graph.tsv" || critical 'An include changed after the final test; no reload occurred'
if ! systemctl reload "$UNIT"; then
  echo "Reload failed: disk hash was ${CANDIDATE_HASH}; master/worker memory is unknown and may still use the prior graph." >&2
  systemctl status "$UNIT" --no-pager || true
  journalctl --unit="$UNIT" --since='-10 minutes' --no-pager || true
  critical 'Keep SSH and rescue artifacts; do not restart blindly. Inspect logs, then use the reviewed rollback with this TX_DIR or retry only after an exact successful test'
fi
systemctl is-active --quiet "$UNIT" || critical 'Reload returned success but the exact unit is not active'
capture_nginx_identity; resolve_main_config
[[ "$EXEC_HASH" == "$START_EXEC_HASH" && "$ARGV_HASH" == "$START_ARGV_HASH" && "$NGINX_BIN" == "$START_BIN" && "$NGINX_CONF" == "$START_CONF" ]] || critical 'Unit identity changed after reload'
[[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || critical 'CONFIG changed after reload'
make_graph_snapshot "${TX_DIR}/after-reload" || critical 'Post-reload graph capture failed'
cmp -s "${TX_DIR}/candidate.graph.tsv" "${TX_DIR}/after-reload.graph.tsv" || critical 'Post-reload graph differs from the tested graph'
printf 'Reload command succeeded; disk and post-check graph match candidate hash=%s. Transaction=%s\n' "$CANDIDATE_HASH" "$TX_DIR"
echo 'Now run the same protected replay as after; only the request and contemporaneous logs establish the result.' >&2

Un reload relee la configuración y reemplaza workers de forma gradual; no es necesario reiniciar para este cambio. Nginx documenta que el proceso maestro intenta aplicar la nueva configuración y conserva la anterior si la aplicación falla, pero la prueba previa sigue siendo una barrera operativa importante. Consulta el flujo oficial de recarga.

5. Alinea PHP-FPM, WordPress y la aplicación

Si Nginx ya acepta la solicitud, PHP todavía puede rechazarla. upload_max_filesize limita un archivo; post_max_size limita el cuerpo POST completo y debe ser mayor. La referencia de PHP también explica que, al superar post_max_size, $_POST y $_FILES quedan vacíos. Para un archivo legítimo de hasta 60 MiB, esta pareja ilustra margen dentro del ejemplo Nginx de 64m; calcula tus propios valores:

; TERAMONT-PHP-413-LIMIT
upload_max_filesize = 60M
post_max_size = 64M

Busca el control que tu pila necesita

Compara planes VPS y confirma acceso, recursos y alcance antes de administrar Nginx y PHP.

Premium Character
Ver planes VPS

WordPress obtiene su máximo de subida a partir del menor de esos dos valores, según wp_max_upload_size(). Por eso puede seguir mostrando 60 MB en este ejemplo. WP_MEMORY_LIMIT no corrige un 413 y .htaccess no controla Nginx. Si necesitas contexto sobre la pila, revisa cómo instalar WordPress en un VPS.

No uses php --ini de la CLI como prueba de PHP-FPM. El flujo parte del fastcgi_pass efectivo y demuestra que el socket coincide con un pool incluido desde el FPM_CONFIG revisado. Lee el ExecStart estructurado de la unidad exacta, compara su binario con /proc/MainPID/exe y conserva en orden el contexto que enumera el código fuente oficial de PHP-FPM, incluidos -c, -d, -n, -y y -p. Si no hay -y, acepta el default solo cuando -tt identifica de forma única el archivo revisado. Como PHP también consulta variables de entorno al buscar INI, ejecuta -tt/-i desde el cwd del master mediante env -i y el vector NUL exacto de su entorno.

La sonda administrativa vive temporalmente bajo /run, solo es legible por el usuario del pool y se llama directamente con cgi-fcgi; nunca crea una URL pública y se elimina de inmediato. Devuelve también getmypid(): el flujo exige que ese PID pertenezca al ControlGroup de la unidad y que su ejecutable sea el binario canónico. Además compara PHP_SAPI, php_ini_loaded_file(), los INI escaneados y los límites contra run_fpm -i, y congela binario, argv, entorno, cwd, cgroup, socket, pool y hashes de configuración antes y después. Si una identidad no puede demostrarse —por ejemplo, con un chroot incompatible— detente y pide una comprobación administrativa restringida; no publiques phpinfo().

set -euo pipefail

die() { echo "ERROR: $*" >&2; exit 1; }
critical() {
  echo "CRITICAL non-convergent: $*" >&2
  echo "Rescue paths: PHP_INI=${PHP_INI:-unknown} TX_DIR=${TX_DIR:-unknown} BACKUP=${BACKUP:-unknown} STATE=${STATE:-unknown}" >&2
  exit 1
}
hash_file() { sha256sum -- "$1" | awk '{print $1}'; }
[[ "$EUID" -eq 0 ]] || die 'Run the reviewed script as root'
for COMMAND in base64 busctl flock getent jq numfmt sha256sum systemctl; do command -v "$COMMAND" >/dev/null || die "Missing command: $COMMAND"; done

NGINX_UNIT='nginx.service'
NGINX_CONFIG='/REPLACE/exact/config-containing-fastcgi-pass.conf'
FASTCGI_PASS='unix:/REPLACE/exact/php-fpm.sock'
PHP_FPM_UNIT='REPLACE_EXACT_FPM_UNIT.service'
FPM_CONFIG='/REPLACE/exact/php-fpm.conf'
POOL_CONFIG='/REPLACE/exact/pool.conf'
PHP_INI='/REPLACE/exact/fpm/php.ini'
EXPECTED_UPLOAD='60M'
EXPECTED_POST='64M'
MARKER='; TERAMONT-PHP-413-LIMIT'
LOCK_DIR='/run/teramont-php-413'
LOCKFILE="${LOCK_DIR}/transaction.lock"
CONFIG_MANAGER_STATE='REPLACE_PAUSED_OR_NONE'
CGI_FCGI_BIN='/usr/bin/cgi-fcgi'

verify_lock_dir() {
  [[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" ]] || return 1
  [[ "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || return 1
  [[ -n "${LOCK_DIR_ID:-}" && "$(stat -Lc '%d:%i' -- "$LOCK_DIR")" == "$LOCK_DIR_ID" ]]
}
verify_lock() {
  local FD_PATH="/proc/$$/fd/${LOCK_FD}" FD_ID PATH_ID FD_META PATH_META
  verify_lock_dir || return 1
  [[ -f "$FD_PATH" && -f "$LOCKFILE" && ! -L "$LOCKFILE" ]] || return 1
  FD_ID="$(stat -Lc '%d:%i' -- "$FD_PATH")" || return 1; PATH_ID="$(stat -Lc '%d:%i' -- "$LOCKFILE")" || return 1
  FD_META="$(stat -Lc '%u:%a:%h' -- "$FD_PATH")" || return 1; PATH_META="$(stat -Lc '%u:%a:%h' -- "$LOCKFILE")" || return 1
  [[ "$FD_ID" == "$PATH_ID" && "$FD_META" == '0:600:1' && "$PATH_META" == "$FD_META" ]]
}
prepare_lock_dir() {
  umask 077
  if [[ -e "$LOCK_DIR" || -L "$LOCK_DIR" ]]; then
    [[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" && "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || die 'Unsafe dedicated FPM lock directory'
  else
    mkdir --mode=700 -- "$LOCK_DIR" || die 'Cannot create the dedicated FPM lock directory'
  fi
  [[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" && "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || die 'Dedicated FPM lock directory failed validation'
  LOCK_DIR_ID="$(stat -Lc '%d:%i' -- "$LOCK_DIR")" || die 'Cannot identify the dedicated FPM lock directory'
  verify_lock_dir || die 'Dedicated FPM lock directory identity changed'
}
acquire_lock() {
  local FD_PATH FD_ID PATH_ID FD_META PATH_META
  prepare_lock_dir
  if [[ -e "$LOCKFILE" || -L "$LOCKFILE" ]]; then
    [[ -f "$LOCKFILE" && ! -L "$LOCKFILE" ]] || die 'Unsafe cooperative FPM lock path'
    [[ "$(stat -Lc '%u:%h' -- "$LOCKFILE")" == '0:1' ]] || die 'Existing FPM lock must be root-owned with one link'
  fi
  exec {LOCK_FD}>>"$LOCKFILE" || die 'Cannot open the cooperative FPM lock without truncation'
  FD_PATH="/proc/$$/fd/${LOCK_FD}"
  [[ -f "$FD_PATH" && -f "$LOCKFILE" && ! -L "$LOCKFILE" ]] || die 'FPM lock type changed during open'
  FD_ID="$(stat -Lc '%d:%i' -- "$FD_PATH")"; PATH_ID="$(stat -Lc '%d:%i' -- "$LOCKFILE")"
  FD_META="$(stat -Lc '%u:%h' -- "$FD_PATH")"; PATH_META="$(stat -Lc '%u:%h' -- "$LOCKFILE")"
  [[ "$FD_ID" == "$PATH_ID" && "$FD_META" == '0:1' && "$PATH_META" == "$FD_META" ]] || die 'FPM lock FD/path identity, owner, or link count is unsafe before chmod'
  chmod 600 -- "$FD_PATH" || die 'Cannot set FPM lock mode through its open descriptor'
  verify_lock || die 'FPM lock FD/path identity is unsafe after descriptor chmod'
  flock --exclusive --nonblock "$LOCK_FD" || die 'Another cooperating FPM transaction holds the lock'
  verify_lock || die 'FPM lock FD/path identity changed after flock'
}
lock_self_test() {
  local TEST_ROOT TEST_DIR TEST_LOCK VICTIM BEFORE_HASH BEFORE_MODE AFTER_HASH AFTER_MODE
  TEST_ROOT="$(mktemp -d /tmp/teramont-php-lock-test.XXXXXXXX)" || die 'Cannot create FPM lock self-test directory'
  chmod 700 -- "$TEST_ROOT"; TEST_DIR="${TEST_ROOT}/lockdir"; TEST_LOCK="${TEST_DIR}/transaction.lock"; VICTIM="${TEST_ROOT}/victim"
  mkdir --mode=700 -- "$TEST_DIR"; printf 'DO-NOT-CHANGE\n' >"$VICTIM"; chmod 640 -- "$VICTIM"
  BEFORE_HASH="$(hash_file "$VICTIM")"; BEFORE_MODE="$(stat -Lc '%a' -- "$VICTIM")"; ln -s -- "$VICTIM" "$TEST_LOCK"
  if (LOCK_DIR="$TEST_DIR"; LOCKFILE="$TEST_LOCK"; unset LOCK_DIR_ID LOCK_FD; acquire_lock) >/dev/null 2>&1; then die 'LOCK_SELF_TEST failed: a symlink lock was accepted'; fi
  AFTER_HASH="$(hash_file "$VICTIM")"; AFTER_MODE="$(stat -Lc '%a' -- "$VICTIM")"
  [[ "$AFTER_HASH" == "$BEFORE_HASH" && "$AFTER_MODE" == "$BEFORE_MODE" ]] || die 'LOCK_SELF_TEST failed: victim bytes or mode changed'
  unlink -- "$TEST_LOCK"; unlink -- "$VICTIM"; rmdir -- "$TEST_DIR" "$TEST_ROOT"
  printf 'LOCK_SELF_TEST=PASS script_aborted=1 victim_hash_before=%s victim_hash_after=%s victim_mode_before=%s victim_mode_after=%s\n' "$BEFORE_HASH" "$AFTER_HASH" "$BEFORE_MODE" "$AFTER_MODE"
}

if [[ "${1:-}" == '--lock-self-test' ]]; then lock_self_test; exit 0; fi

for FILE in "$NGINX_CONFIG" "$FPM_CONFIG" "$POOL_CONFIG" "$PHP_INI"; do
  [[ "$FILE" == /* && "$FILE" != /REPLACE* && -f "$FILE" && ! -L "$FILE" ]] || die "Replace with an exact regular, non-symlink path: $FILE"
done
[[ "$FASTCGI_PASS" == unix:/* && "$FASTCGI_PASS" != unix:/REPLACE* ]] || die 'Replace FASTCGI_PASS with the effective Unix socket'
[[ "$PHP_FPM_UNIT" != REPLACE* && "$PHP_FPM_UNIT" =~ ^[a-zA-Z0-9_.@-]+\.service$ ]] || die 'Replace PHP_FPM_UNIT with one exact unit; never use a service glob'
case "$CONFIG_MANAGER_STATE" in PAUSED|NONE_CONFIRMED) ;; *) die 'Pause the config manager/deployer, or set NONE_CONFIRMED after proving none controls these files' ;; esac
[[ -x "$CGI_FCGI_BIN" ]] || die 'Install cgi-fcgi or stop and have an administrator design an equally restricted probe'
[[ "$EXPECTED_UPLOAD" =~ ^[1-9][0-9]*[KMG]$ && "$EXPECTED_POST" =~ ^[1-9][0-9]*[KMG]$ ]] || die 'Expected PHP limits must be finite shorthand values'
UPLOAD_BYTES="$(numfmt --from=iec "$EXPECTED_UPLOAD")"; POST_BYTES="$(numfmt --from=iec "$EXPECTED_POST")"
(( POST_BYTES > UPLOAD_BYTES )) || die 'EXPECTED_POST must exceed EXPECTED_UPLOAD'
NGINX_CONFIG="$(readlink -f -- "$NGINX_CONFIG")"; FPM_CONFIG="$(readlink -f -- "$FPM_CONFIG")"; POOL_CONFIG="$(readlink -f -- "$POOL_CONFIG")"; PHP_INI="$(readlink -f -- "$PHP_INI")"
FASTCGI_SOCKET="${FASTCGI_PASS#unix:}"
[[ "$FASTCGI_SOCKET" == /* && -S "$FASTCGI_SOCKET" && ! -L "$FASTCGI_SOCKET" ]] || die 'Effective FastCGI socket must exist as a non-symlink Unix socket'

get_unit_path() {
  local UNIT="$1" REPLY
  REPLY="$(busctl --json=short call org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager GetUnit s "$UNIT")" || die "D-Bus GetUnit failed for $UNIT"
  UNIT_PATH="$(jq -er 'select(.type=="o") | if (.data|type)=="string" then .data elif ((.data|type)=="array" and (.data|length)==1 and (.data[0]|type)=="string") then .data[0] else error("ambiguous") end' <<<"$REPLY")" || die "Cannot decode unit path for $UNIT; stop and consult the administrator"
}

capture_nginx() {
  local EXEC_PATH ARGV0 PROC_BIN
  get_unit_path "$NGINX_UNIT"; NGINX_UNIT_PATH="$UNIT_PATH"
  NGINX_EXEC_JSON="$(busctl --json=short get-property org.freedesktop.systemd1 "$NGINX_UNIT_PATH" org.freedesktop.systemd1.Service ExecStart)" || die 'Cannot read structured Nginx ExecStart'
  jq -e '.type=="a(sasbttttuii)" and (.data|type)=="array" and (.data|length)==1 and (.data[0]|length)==10 and (.data[0][1]|type)=="array" and (.data[0][1]|length)>=1 and all(.data[0][1][];type=="string")' <<<"$NGINX_EXEC_JSON" >/dev/null || die 'Nginx ExecStart is ambiguous; stop and consult the administrator'
  mapfile -d '' -t NGINX_ARGV < <(jq -j '.data[0][1][] | ., "\u0000"' <<<"$NGINX_EXEC_JSON")
  EXEC_PATH="$(jq -er '.data[0][0]' <<<"$NGINX_EXEC_JSON")"; NGINX_BIN="$(readlink -f -- "$EXEC_PATH")"; ARGV0="$(readlink -f -- "${NGINX_ARGV[0]}")"
  NGINX_PID="$(systemctl show --property=MainPID --value "$NGINX_UNIT")"; [[ "$NGINX_PID" =~ ^[1-9][0-9]*$ ]] || die 'Nginx MainPID is unavailable'
  PROC_BIN="$(readlink -f -- "/proc/${NGINX_PID}/exe")"
  [[ "$NGINX_BIN" == "$ARGV0" && "$NGINX_BIN" == "$PROC_BIN" ]] || die 'Nginx structured ExecStart and process binary disagree'
  NGINX_CONTEXT=("${NGINX_ARGV[@]:1}")
  for ARG in "${NGINX_CONTEXT[@]}"; do case "$ARG" in -s|-s*|-t|-T|-q|-v|-V|-h|-\?) die 'Nginx ExecStart contains an action flag unsafe to reuse' ;; esac; done
  NGINX_EXEC_HASH="$(jq -cS . <<<"$NGINX_EXEC_JSON" | sha256sum | awk '{print $1}')"
  NGINX_ARGV_HASH="$({ printf '%s\0' "${NGINX_ARGV[@]}"; } | sha256sum | awk '{print $1}')"
}

capture_nginx
NGINX_DUMP="$(mktemp --tmpdir=/root .nginx-php-link.XXXXXXXX)"; NGINX_SECTION="${NGINX_DUMP}.section"
"$NGINX_BIN" -T "${NGINX_CONTEXT[@]}" >"$NGINX_DUMP" || die 'Nginx -T failed with the full structured context'
NGINX_HEADER="# configuration file ${NGINX_CONFIG}:"
awk -v h="$NGINX_HEADER" '$0==h{seen++;active=1;next} /^# configuration file /&&active{active=0} active{print} END{if(seen!=1)exit 42}' "$NGINX_DUMP" >"$NGINX_SECTION" || die 'NGINX_CONFIG header is missing or ambiguous'
FASTCGI_COUNT="$(awk -v expected="fastcgi_pass ${FASTCGI_PASS};" '{v=$0;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);gsub(/[[:space:]]+/," ",v);if(v==expected)c++}END{print c+0}' "$NGINX_SECTION")"
[[ "$FASTCGI_COUNT" == 1 ]] || die 'Confirmed Nginx section must contain one normalized exact fastcgi_pass'
awk -v expected="fastcgi_pass ${FASTCGI_PASS};" '{v=$0;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);gsub(/[[:space:]]+/," ",v)} /^[[:space:]]*server[[:space:]]*\{/ {server=$0;location=""} /^[[:space:]]*location[[:space:]]/ {location=$0} v==expected {print "Server seen: " server;print "Location seen: " location;print "fastcgi_pass line: " NR ":" $0}' "$NGINX_SECTION"
echo 'Confirm manually that this is the location selected by the real upload URL; an exact match does not solve location precedence.' >&2
read -r -p 'Type CONFIRM-FASTCGI-PATH after tracing the request path: ' CONFIRM
[[ "$CONFIRM" == 'CONFIRM-FASTCGI-PATH' ]] || die 'FastCGI path was not confirmed'

read_pool_file() {
  mapfile -t POOL_NAMES < <(awk '/^[[:space:]]*\[[^]]+\][[:space:]]*$/ {v=$0;gsub(/^[[:space:]]*\[|\][[:space:]]*$/,"",v);print v}' "$POOL_CONFIG")
  mapfile -t POOL_LISTENS < <(awk -F= '/^[[:space:]]*(;|#)/{next} $1~/^[[:space:]]*listen[[:space:]]*$/{v=$2;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v}' "$POOL_CONFIG")
  mapfile -t POOL_USERS < <(awk -F= '/^[[:space:]]*(;|#)/{next} $1~/^[[:space:]]*user[[:space:]]*$/{v=$2;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v}' "$POOL_CONFIG")
  mapfile -t POOL_GROUPS < <(awk -F= '/^[[:space:]]*(;|#)/{next} $1~/^[[:space:]]*group[[:space:]]*$/{v=$2;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v}' "$POOL_CONFIG")
  [[ ${#POOL_NAMES[@]} == 1 && ${#POOL_LISTENS[@]} == 1 && ${#POOL_USERS[@]} == 1 && ${#POOL_GROUPS[@]} == 1 ]] || die 'POOL_CONFIG must define one unambiguous pool/name/listen/user/group'
  POOL_NAME="${POOL_NAMES[0]}"; POOL_USER="${POOL_USERS[0]}"; POOL_GROUP="${POOL_GROUPS[0]}"
  [[ "${POOL_LISTENS[0]}" == "$FASTCGI_SOCKET" ]] || die 'POOL_CONFIG listen does not match effective fastcgi_pass'
  id "$POOL_USER" >/dev/null || die 'Pool user does not exist'; getent group "$POOL_GROUP" >/dev/null || die 'Pool group does not exist'
}
read_pool_file

capture_fpm() {
  local EXEC_PATH ARGV0 PROC_BIN MASTER_CGROUP
  get_unit_path "$PHP_FPM_UNIT"; FPM_UNIT_PATH="$UNIT_PATH"
  FPM_EXEC_JSON="$(busctl --json=short get-property org.freedesktop.systemd1 "$FPM_UNIT_PATH" org.freedesktop.systemd1.Service ExecStart)" || die 'Cannot read structured FPM ExecStart'
  jq -e '.type=="a(sasbttttuii)" and (.data|type)=="array" and (.data|length)==1 and (.data[0]|length)==10 and (.data[0][1]|type)=="array" and (.data[0][1]|length)>=1 and all(.data[0][1][];type=="string")' <<<"$FPM_EXEC_JSON" >/dev/null || die 'FPM ExecStart is not one unambiguous structured command; stop and consult the administrator'
  mapfile -d '' -t FPM_ARGV < <(jq -j '.data[0][1][] | ., "\u0000"' <<<"$FPM_EXEC_JSON")
  EXEC_PATH="$(jq -er '.data[0][0]' <<<"$FPM_EXEC_JSON")"; FPM_BIN="$(readlink -f -- "$EXEC_PATH")"; ARGV0="$(readlink -f -- "${FPM_ARGV[0]}")"
  FPM_PID="$(systemctl show --property=MainPID --value "$PHP_FPM_UNIT")"; [[ "$FPM_PID" =~ ^[1-9][0-9]*$ ]] || die 'FPM MainPID is unavailable'
  PROC_BIN="$(readlink -f -- "/proc/${FPM_PID}/exe")"
  [[ -x "$FPM_BIN" && "$FPM_BIN" == "$ARGV0" && "$FPM_BIN" == "$PROC_BIN" ]] || die 'FPM structured ExecStart, argv[0], and process binary disagree'
  FPM_CONTEXT=("${FPM_ARGV[@]:1}")
  FPM_CONFIG_ARG=''; FPM_CONFIG_COUNT=0
  for ((I=0; I<${#FPM_CONTEXT[@]}; I++)); do
    case "${FPM_CONTEXT[I]}" in
      -t|-tt|-i|-m|-v|--test|--phpinfo|--modules|--version) die 'FPM ExecStart contains an action flag unsafe to reuse' ;;
      -c|--php-ini|-d|--define|-y|--fpm-config|-p|--prefix)
        ((I+1<${#FPM_CONTEXT[@]})) || die "FPM option ${FPM_CONTEXT[I]} lacks a value"
        case "${FPM_CONTEXT[I]}" in
          -y|--fpm-config) FPM_CONFIG_ARG="${FPM_CONTEXT[I+1]}"; ((FPM_CONFIG_COUNT+=1)) ;;
          -c|--php-ini|-p|--prefix) [[ "${FPM_CONTEXT[I+1]}" == /* ]] || die "FPM path for ${FPM_CONTEXT[I]} must be absolute" ;;
        esac
        ((I+=1)) ;;
      -y?*) FPM_CONFIG_ARG="${FPM_CONTEXT[I]#-y}"; ((FPM_CONFIG_COUNT+=1)) ;;
      --fpm-config=*) FPM_CONFIG_ARG="${FPM_CONTEXT[I]#*=}"; ((FPM_CONFIG_COUNT+=1)) ;;
      -c?*|--php-ini=*|-p?*|--prefix=*)
        VALUE="${FPM_CONTEXT[I]}"; VALUE="${VALUE#-c}"; VALUE="${VALUE#-p}"; VALUE="${VALUE#*=}"; [[ "$VALUE" == /* ]] || die 'Compact FPM config/prefix paths must be absolute' ;;
      -d?*|--define=*|-n|--no-php-ini|--nodaemonize|--force-stderr) ;;
    esac
  done
  (( FPM_CONFIG_COUNT <= 1 )) || die 'FPM config argument is ambiguous'
  if (( FPM_CONFIG_COUNT == 1 )); then [[ "$FPM_CONFIG_ARG" == /* && "$(readlink -f -- "$FPM_CONFIG_ARG")" == "$FPM_CONFIG" ]] || die 'Structured -y/--fpm-config does not identify FPM_CONFIG'; fi
  FPM_EXEC_HASH="$(jq -cS . <<<"$FPM_EXEC_JSON" | sha256sum | awk '{print $1}')"
  FPM_ARGV_HASH="$({ printf '%s\0' "${FPM_ARGV[@]}"; } | sha256sum | awk '{print $1}')"
  FPM_CWD="$(readlink -f -- "/proc/${FPM_PID}/cwd")"; [[ "$FPM_CWD" == /* && -d "$FPM_CWD" ]] || die 'Cannot identify the FPM master cwd'
  FPM_CGROUP="$(systemctl show --property=ControlGroup --value "$PHP_FPM_UNIT")"; [[ "$FPM_CGROUP" == /* ]] || die 'Cannot identify the FPM ControlGroup'
  MASTER_CGROUP="$(awk -F: '$1=="0"{print $3}' "/proc/${FPM_PID}/cgroup")"
  [[ "$MASTER_CGROUP" == "$FPM_CGROUP" || "$MASTER_CGROUP" == "$FPM_CGROUP"/* ]] || die 'FPM MainPID is outside the unit ControlGroup'
}

systemctl is-active --quiet "$PHP_FPM_UNIT" || die 'The exact FPM unit is not active'
capture_fpm
START_FPM_EXEC_HASH="$FPM_EXEC_HASH"; START_FPM_ARGV_HASH="$FPM_ARGV_HASH"; START_FPM_BIN="$FPM_BIN"; START_FPM_CWD="$FPM_CWD"; START_FPM_CGROUP="$FPM_CGROUP"
START_NGINX_EXEC_HASH="$NGINX_EXEC_HASH"; START_NGINX_ARGV_HASH="$NGINX_ARGV_HASH"

acquire_lock

BASE_HASH="$(hash_file "$PHP_INI")"; BASE_UID="$(stat -Lc '%u' "$PHP_INI")"; BASE_GID="$(stat -Lc '%g' "$PHP_INI")"; BASE_MODE="$(stat -Lc '%a' "$PHP_INI")"
[[ "$BASE_UID" == 0 ]] || die 'PHP_INI and transaction artifacts must be root-owned'
INI_DIR="$(dirname -- "$PHP_INI")"; TX_DIR="$(mktemp -d --tmpdir="$INI_DIR" .php-413.XXXXXXXX)"; chmod 700 "$TX_DIR"
[[ "$(stat -Lc '%d:%u:%a' "$TX_DIR")" == "$(stat -Lc '%d' "$INI_DIR"):0:700" ]] || critical 'Transaction directory is not protected on PHP_INI filesystem'
BACKUP="${TX_DIR}/php.ini.before"; BACKUP_SHA="${TX_DIR}/php.ini.before.sha256"; CANDIDATE="${TX_DIR}/php.ini.candidate"; STATE="${TX_DIR}/transaction.json"
cp --archive -- "$PHP_INI" "$BACKUP"; cp --archive -- "$PHP_INI" "$CANDIDATE"
[[ "$(hash_file "$BACKUP")" == "$BASE_HASH" ]] || critical 'PHP backup hash differs from base'
printf '%s  %s\n' "$BASE_HASH" "$BACKUP" >"$BACKUP_SHA"; chmod 600 "$BACKUP_SHA"; sha256sum --check "$BACKUP_SHA" || critical 'Persistent PHP backup SHA failed'

ACTIVE_ENV_FILE="${TX_DIR}/fpm.environment.before.nul"; cp -- "/proc/${FPM_PID}/environ" "$ACTIVE_ENV_FILE"; chmod 600 "$ACTIVE_ENV_FILE"
ACTIVE_ENV_HASH="$(hash_file "$ACTIVE_ENV_FILE")"; mapfile -d '' -t MASTER_ENV <"$ACTIVE_ENV_FILE"
for ENTRY in "${MASTER_ENV[@]}"; do NAME="${ENTRY%%=*}"; [[ "$ENTRY" == *=* && "$NAME" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || die 'FPM master environment contains an entry unsafe for env -i'; done
run_fpm() { (cd "$FPM_CWD" && env -i "${MASTER_ENV[@]}" "$FPM_BIN" "${FPM_CONTEXT[@]}" "$@"); }

collect_fpm_graph() {
  local PREFIX="$1" CURRENT PATTERN MATCH; declare -A SEEN=(); local -a QUEUE=("$FPM_CONFIG") MATCHES=()
  : >"${PREFIX}.paths"
  while ((${#QUEUE[@]})); do
    CURRENT="${QUEUE[0]}"; QUEUE=("${QUEUE[@]:1}")
    [[ "$CURRENT" == /* && -f "$CURRENT" && ! -L "$CURRENT" ]] || return 1
    CURRENT="$(readlink -f -- "$CURRENT")"; [[ -z "${SEEN[$CURRENT]:-}" ]] || continue; SEEN[$CURRENT]=1; printf '%s\n' "$CURRENT" >>"${PREFIX}.paths"
    while IFS= read -r PATTERN; do
      [[ "$PATTERN" == /* && "$PATTERN" != *$'\t'* && "$PATTERN" != *' '* ]] || return 1
      mapfile -t MATCHES < <(compgen -G "$PATTERN" | LC_ALL=C sort)
      ((${#MATCHES[@]})) || return 1
      for MATCH in "${MATCHES[@]}"; do QUEUE+=("$MATCH"); done
    done < <(awk -F= '/^[[:space:]]*include[[:space:]]*=/{v=$0;sub(/^[^=]*=/,"",v);gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v}' "$CURRENT")
  done
  LC_ALL=C sort -u -o "${PREFIX}.paths" "${PREFIX}.paths"; : >"${PREFIX}.graph.tsv"
  while IFS= read -r CURRENT; do printf '%s\t%s\n' "$CURRENT" "$(hash_file "$CURRENT")" >>"${PREFIX}.graph.tsv"; done <"${PREFIX}.paths"
  chmod 600 "${PREFIX}.paths" "${PREFIX}.graph.tsv"
}
normalize_ini_list() {
  local RAW="$1" DEST="$2" ITEM
  : >"$DEST"
  while IFS= read -r ITEM; do
    ITEM="$(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' <<<"$ITEM")"
    [[ -z "$ITEM" || "$ITEM" == '(none)' ]] && continue
    [[ "$ITEM" == /* && -f "$ITEM" && ! -L "$ITEM" ]] || return 1
    readlink -f -- "$ITEM" >>"$DEST"
  done < <(printf '%s\n' "$RAW" | tr ',' '\n')
  chmod 600 "$DEST"
}
capture_fpm_info() {
  local PREFIX="$1" RAW LOADED
  run_fpm -i >"${PREFIX}.info" || return 1; chmod 600 "${PREFIX}.info"
  grep -Fq 'Server API => FPM/FastCGI' "${PREFIX}.info" || return 1
  mapfile -t LOADED_VALUES < <(awk -F ' => ' '$1=="Loaded Configuration File"{print $2}' "${PREFIX}.info")
  [[ ${#LOADED_VALUES[@]} == 1 ]] || return 1
  LOADED="$(readlink -f -- "${LOADED_VALUES[0]}")"; [[ "$LOADED" == "$PHP_INI" ]] || return 1
  RAW="$(awk '/^Additional .ini files parsed => /{active=1;sub(/^Additional .ini files parsed => /,"");print;next} active&&/^[^[:space:]].* => /{exit} active{print}' "${PREFIX}.info")"
  normalize_ini_list "$RAW" "${PREFIX}.scanned" || return 1
  { printf '%s\n' "$PHP_INI"; cat "${PREFIX}.scanned"; } | LC_ALL=C sort -u >"${PREFIX}.php.paths"
  : >"${PREFIX}.php.graph.tsv"; while IFS= read -r FILE; do printf '%s\t%s\n' "$FILE" "$(hash_file "$FILE")" >>"${PREFIX}.php.graph.tsv"; done <"${PREFIX}.php.paths"
  chmod 600 "${PREFIX}.php.paths" "${PREFIX}.php.graph.tsv"
}
validate_tt_pool() {
  local FILE="$1" NORMAL="${1}.normalized"
  sed -E 's/^\[[^]]+\][[:space:]]+NOTICE:[[:space:]]*//' "$FILE" >"$NORMAL"; chmod 600 "$NORMAL"
  [[ "$(grep -Fxc -- "[$POOL_NAME]" "$NORMAL")" == 1 ]] || return 1
  mapfile -t TT_LISTENS < <(awk -F= -v p="$POOL_NAME" '$0=="["p"]"{seen++;active=1;next} /^\[[^]]+\]$/{active=0} active&&$1~/^[[:space:]]*listen[[:space:]]*$/{v=$2;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v} END{if(seen!=1)exit 42}' "$NORMAL") || return 1
  mapfile -t TT_USERS < <(awk -F= -v p="$POOL_NAME" '$0=="["p"]"{active=1;next} /^\[[^]]+\]$/{active=0} active&&$1~/^[[:space:]]*user[[:space:]]*$/{v=$2;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v}' "$NORMAL")
  mapfile -t TT_GROUPS < <(awk -F= -v p="$POOL_NAME" '$0=="["p"]"{active=1;next} /^\[[^]]+\]$/{active=0} active&&$1~/^[[:space:]]*group[[:space:]]*$/{v=$2;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v}' "$NORMAL")
  [[ ${#TT_LISTENS[@]} == 1 && "${TT_LISTENS[0]}" == "$FASTCGI_SOCKET" && ${#TT_USERS[@]} == 1 && "${TT_USERS[0]}" == "$POOL_USER" && ${#TT_GROUPS[@]} == 1 && "${TT_GROUPS[0]}" == "$POOL_GROUP" ]]
}

collect_fpm_graph "${TX_DIR}/fpm-base" || die 'Cannot resolve the FPM include graph; require absolute, unambiguous include patterns'
grep -Fqx "$POOL_CONFIG" "${TX_DIR}/fpm-base.paths" || die 'POOL_CONFIG is not proven to be included from FPM_CONFIG'
FPM_GRAPH_HASH="$(hash_file "${TX_DIR}/fpm-base.graph.tsv")"
run_fpm -tt >"${TX_DIR}/fpm-base.tt" 2>&1 || die 'Starting FPM -tt failed with full structured argv, clean master env, and master cwd'
chmod 600 "${TX_DIR}/fpm-base.tt"; validate_tt_pool "${TX_DIR}/fpm-base.tt" || die 'FPM -tt does not show one exact pool/listen/user/group'
if (( FPM_CONFIG_COUNT == 0 )); then
  [[ "$(grep -Foc -- "$FPM_CONFIG" "${TX_DIR}/fpm-base.tt")" == 1 ]] || die 'No -y was configured and -tt did not uniquely identify the reviewed default FPM_CONFIG; stop and consult the administrator'
fi
capture_fpm_info "${TX_DIR}/php-base" || die 'FPM -i did not identify FPM/FastCGI, PHP_INI, and scanned INIs unambiguously'
PHP_GRAPH_BASE_HASH="$(hash_file "${TX_DIR}/php-base.php.graph.tsv")"

PROBE_DIR=''
cleanup_probe() { local RC=0; if [[ -n "$PROBE_DIR" && -d "$PROBE_DIR" && ! -L "$PROBE_DIR" ]]; then [[ "$(stat -Lc '%u' "$PROBE_DIR")" == 0 ]] || RC=1; find "$PROBE_DIR" -maxdepth 1 -type f -delete || RC=1; rmdir -- "$PROBE_DIR" || RC=1; PROBE_DIR=''; fi; return "$RC"; }
trap 'if ! cleanup_probe; then echo "CRITICAL non-convergent: temporary probe cleanup failed" >&2; fi' EXIT
probe_fpm() {
  local DEST="$1" RAW="${1}.raw" PROBE PID WORKER_BIN WORKER_CGROUP
  PROBE_DIR="$(mktemp -d --tmpdir=/run .teramont-fpm-probe.XXXXXXXX)"; chown "root:$POOL_GROUP" "$PROBE_DIR"; chmod 710 "$PROBE_DIR"
  PROBE="${PROBE_DIR}/probe.php"
  cat >"$PROBE" <<'PHP'
<?php
header('Content-Type: application/json');
echo json_encode([
    'pid' => getmypid(),
    'sapi' => PHP_SAPI,
    'loaded_ini' => php_ini_loaded_file(),
    'scanned_ini' => php_ini_scanned_files(),
    'upload_max_filesize' => ini_get('upload_max_filesize'),
    'post_max_size' => ini_get('post_max_size'),
], JSON_THROW_ON_ERROR);
PHP
  chown "$POOL_USER:$POOL_GROUP" "$PROBE"; chmod 400 "$PROBE"
  env -i SCRIPT_FILENAME="$PROBE" SCRIPT_NAME='/restricted-local-probe.php' REQUEST_METHOD='GET' SERVER_PROTOCOL='HTTP/1.1' GATEWAY_INTERFACE='CGI/1.1' REDIRECT_STATUS='200' "$CGI_FCGI_BIN" -bind -connect "$FASTCGI_SOCKET" >"$RAW" || { cleanup_probe || critical 'Probe failure plus cleanup failure'; return 1; }
  cleanup_probe || critical 'Probe completed but temporary file removal failed'
  sed '1,/^\r$/d' "$RAW" >"$DEST"; chmod 600 "$RAW" "$DEST"; jq empty "$DEST" || return 1
  PID="$(jq -er '.pid|select(type=="number" and .>1)|floor' "$DEST")" || return 1
  [[ -e "/proc/${PID}/exe" ]] || return 1; WORKER_BIN="$(readlink -f -- "/proc/${PID}/exe")"; [[ "$WORKER_BIN" == "$FPM_BIN" ]] || return 1
  WORKER_CGROUP="$(awk -F: '$1=="0"{print $3}' "/proc/${PID}/cgroup")"; [[ "$WORKER_CGROUP" == "$FPM_CGROUP" || "$WORKER_CGROUP" == "$FPM_CGROUP"/* ]] || return 1
  jq -e --arg ini "$PHP_INI" '.sapi=="fpm-fcgi" and .loaded_ini==$ini and (.scanned_ini|type)=="string" and (.upload_max_filesize|type)=="string" and (.post_max_size|type)=="string"' "$DEST" >/dev/null || return 1
  normalize_ini_list "$(jq -r '.scanned_ini' "$DEST")" "${DEST}.scanned" || return 1
}

SOCKET_BEFORE="$(stat -Lc '%d:%i:%u:%g:%a:%F' "$FASTCGI_SOCKET")"
BEFORE_PROBE="${TX_DIR}/probe.before.json"; probe_fpm "$BEFORE_PROBE" || die 'Restricted local probe did not tie SAPI/PID/exe/cgroup/INI to the selected unit'
cmp -s "${TX_DIR}/php-base.scanned" "${BEFORE_PROBE}.scanned" || die 'FPM -i scanned INIs differ from the live worker probe'

echo "Edit only this candidate: $CANDIDATE" >&2
printf '%s\nupload_max_filesize = %s\npost_max_size = %s\n' "$MARKER" "$EXPECTED_UPLOAD" "$EXPECTED_POST" >&2
[[ -x /usr/bin/vi ]] || die '/usr/bin/vi is unavailable'; /usr/bin/vi "$CANDIDATE"
[[ -f "$CANDIDATE" && ! -L "$CANDIDATE" ]] || die 'PHP candidate changed type'; chown "$BASE_UID:$BASE_GID" "$CANDIDATE"; chmod "$BASE_MODE" "$CANDIDATE"
PHP_MARK_COUNT="$(awk -v m="$MARKER" '$0==m{c++}END{print c+0}' "$CANDIDATE")"
PHP_PAIR_COUNT="$(awk -v m="$MARKER" -v u="upload_max_filesize = $EXPECTED_UPLOAD" -v p="post_max_size = $EXPECTED_POST" '$0==m{getline a;getline b;if(a==u&&b==p)c++}END{print c+0}' "$CANDIDATE")"
[[ "$PHP_MARK_COUNT" == 1 && "$PHP_PAIR_COUNT" == 1 ]] || die 'PHP candidate needs one exact marker/upload/post triple'
DIFF_RC=0; diff -u -- "$BACKUP" "$CANDIDATE" || DIFF_RC=$?; [[ "$DIFF_RC" == 1 ]] || die 'PHP candidate is unchanged or diff failed'
read -r -p 'Type APPLY-PHP-413 after reviewing diff, unit, argv, environment, pool, socket, and INI graph: ' CONFIRM
[[ "$CONFIRM" == 'APPLY-PHP-413' ]] || die 'Cancelled before installing candidate'

CANDIDATE_HASH="$(hash_file "$CANDIDATE")"
verify_lock || critical 'Lock identity changed before PHP installation'
[[ "$(hash_file "$PHP_INI")" == "$BASE_HASH" && "$(hash_file "${TX_DIR}/fpm-base.graph.tsv")" == "$FPM_GRAPH_HASH" && "$(hash_file "${TX_DIR}/php-base.php.graph.tsv")" == "$PHP_GRAPH_BASE_HASH" ]] || critical 'A frozen config changed before installation'
collect_fpm_graph "${TX_DIR}/fpm-base-recheck" || critical 'FPM graph recheck failed'; cmp -s "${TX_DIR}/fpm-base.graph.tsv" "${TX_DIR}/fpm-base-recheck.graph.tsv" || critical 'FPM include graph changed before installation'
capture_fpm_info "${TX_DIR}/php-base-recheck" || critical 'PHP graph recheck failed'; cmp -s "${TX_DIR}/php-base.php.graph.tsv" "${TX_DIR}/php-base-recheck.php.graph.tsv" || critical 'PHP INI graph changed before installation'
unexpected_after_mutation() { local RC=$?; trap - ERR; critical "unexpected command failure after mutation (rc=${RC}, line=${BASH_LINENO[0]:-unknown}); state was not assumed"; }
trap unexpected_after_mutation ERR
mv -T -- "$CANDIDATE" "$PHP_INI" || critical 'Atomic PHP candidate rename failed'
[[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" && "$(stat -Lc '%u:%g:%a' "$PHP_INI")" == "${BASE_UID}:${BASE_GID}:${BASE_MODE}" ]] || critical 'Installed PHP candidate hash or attributes mismatch'

recover_php_after_failure() {
  local REASON="$1" CURRENT_COPY="${TX_DIR}/php.candidate.rescue" STAGE="${TX_DIR}/php.base.restore.stage"
  [[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" ]] || critical "$REASON; active bytes changed, so no automatic overwrite was attempted"
  cp --archive -- "$PHP_INI" "$CURRENT_COPY" || critical "$REASON; could not preserve current bytes"; [[ "$(hash_file "$CURRENT_COPY")" == "$CANDIDATE_HASH" ]] || critical "$REASON; current rescue hash mismatch"
  cp --archive -- "$BACKUP" "$STAGE" || critical "$REASON; could not stage backup"; chown "$BASE_UID:$BASE_GID" "$STAGE"; chmod "$BASE_MODE" "$STAGE"
  [[ "$(hash_file "$STAGE")" == "$BASE_HASH" && "$(stat -Lc '%d' "$STAGE")" == "$(stat -Lc '%d' "$PHP_INI")" ]] || critical "$REASON; base stage mismatch"
  mv -T -- "$STAGE" "$PHP_INI" || critical "$REASON; atomic base rename failed"; [[ "$(hash_file "$PHP_INI")" == "$BASE_HASH" ]] || critical "$REASON; base hash is not on disk"
  if ! run_fpm -tt >/dev/null 2>&1; then
    STAGE="${TX_DIR}/php.current.restore.stage"; cp --archive -- "$CURRENT_COPY" "$STAGE" || critical "$REASON; base test failed and current bytes could not be staged"
    chown "$BASE_UID:$BASE_GID" "$STAGE"; chmod "$BASE_MODE" "$STAGE"; mv -T -- "$STAGE" "$PHP_INI" || critical "$REASON; current atomic rename failed"
    [[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" ]] || critical "$REASON; current hash was not recovered"
    run_fpm -tt >/dev/null 2>&1 || critical "$REASON; base and repeated current tests failed; no reload occurred"
    critical "$REASON; base test failed, current hash/tests are recorded, and no reload occurred"
  fi
  critical "$REASON; no reload occurred; inspect the recorded disk hash and rescue paths before any next action"
}

[[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" ]] || critical 'Candidate changed immediately before -tt'
run_fpm -tt >"${TX_DIR}/fpm-candidate.tt" 2>&1 || recover_php_after_failure 'Candidate FPM -tt failed'; chmod 600 "${TX_DIR}/fpm-candidate.tt"
validate_tt_pool "${TX_DIR}/fpm-candidate.tt" || recover_php_after_failure 'Candidate -tt pool/listen/user/group identity failed'
collect_fpm_graph "${TX_DIR}/fpm-candidate" || recover_php_after_failure 'Candidate FPM graph failed'; cmp -s "${TX_DIR}/fpm-base.graph.tsv" "${TX_DIR}/fpm-candidate.graph.tsv" || recover_php_after_failure 'FPM config/pool graph changed outside PHP_INI'
capture_fpm_info "${TX_DIR}/php-candidate" || recover_php_after_failure 'Candidate FPM -i identity failed'
awk -F '\t' -v ini="$PHP_INI" -v h="$CANDIDATE_HASH" 'BEGIN{OFS="\t"}$1==ini{$2=h}{print}' "${TX_DIR}/php-base.php.graph.tsv" >"${TX_DIR}/php-candidate.expected.graph.tsv"; chmod 600 "${TX_DIR}/php-candidate.expected.graph.tsv"
cmp -s "${TX_DIR}/php-candidate.expected.graph.tsv" "${TX_DIR}/php-candidate.php.graph.tsv" || recover_php_after_failure 'PHP graph changed beyond reviewed PHP_INI bytes'
PHP_GRAPH_CANDIDATE_HASH="$(hash_file "${TX_DIR}/php-candidate.php.graph.tsv")"

FPM_ARGV_B64="$({ printf '%s\0' "${FPM_ARGV[@]}"; } | base64 -w0)"
jq -nS --arg unit "$PHP_FPM_UNIT" --arg fpm_config "$FPM_CONFIG" --arg pool_config "$POOL_CONFIG" --arg pool_name "$POOL_NAME" --arg pool_user "$POOL_USER" --arg pool_group "$POOL_GROUP" \
  --arg socket "$FASTCGI_SOCKET" --arg socket_before "$SOCKET_BEFORE" --arg php_ini "$PHP_INI" --arg fpm_bin "$FPM_BIN" --arg exec_hash "$START_FPM_EXEC_HASH" --arg argv_hash "$START_FPM_ARGV_HASH" --arg argv_b64 "$FPM_ARGV_B64" \
  --arg env_hash "$ACTIVE_ENV_HASH" --arg cwd "$FPM_CWD" --arg cgroup "$FPM_CGROUP" --arg base_hash "$BASE_HASH" --arg candidate_hash "$CANDIDATE_HASH" \
  --arg fpm_graph_hash "$FPM_GRAPH_HASH" --arg php_base_graph_hash "$PHP_GRAPH_BASE_HASH" --arg php_candidate_graph_hash "$PHP_GRAPH_CANDIDATE_HASH" \
  --arg backup "$BACKUP" --arg backup_sha "$BACKUP_SHA" --arg lockdir "$LOCK_DIR" --arg lockfile "$LOCKFILE" --arg manager_state "$CONFIG_MANAGER_STATE" --arg before_probe "$BEFORE_PROBE" --arg before_probe_sha256 "$(hash_file "$BEFORE_PROBE")" --arg cgi "$CGI_FCGI_BIN" \
  --argjson uid "$BASE_UID" --argjson gid "$BASE_GID" --arg mode "$BASE_MODE" \
  '{unit:$unit,fpm_config:$fpm_config,pool_config:$pool_config,pool_name:$pool_name,pool_user:$pool_user,pool_group:$pool_group,socket:$socket,socket_before:$socket_before,php_ini:$php_ini,fpm_bin:$fpm_bin,execstart_sha256:$exec_hash,argv_sha256:$argv_hash,argv_b64:$argv_b64,environment_sha256:$env_hash,cwd:$cwd,cgroup:$cgroup,base_hash:$base_hash,candidate_hash:$candidate_hash,fpm_graph_hash:$fpm_graph_hash,php_base_graph_hash:$php_base_graph_hash,php_candidate_graph_hash:$php_candidate_graph_hash,backup:$backup,backup_sha:$backup_sha,lock_dir:$lockdir,lockfile:$lockfile,config_manager_state:$manager_state,before_probe:$before_probe,before_probe_sha256:$before_probe_sha256,cgi_fcgi:$cgi,base_uid:$uid,base_gid:$gid,base_mode:$mode}' >"${STATE}.tmp"
chmod 600 "${STATE}.tmp"; mv -T -- "${STATE}.tmp" "$STATE"

recheck_fpm_identity() {
  local ENV_DEST="$1"
  capture_fpm
  [[ "$FPM_EXEC_HASH" == "$START_FPM_EXEC_HASH" && "$FPM_ARGV_HASH" == "$START_FPM_ARGV_HASH" && "$FPM_BIN" == "$START_FPM_BIN" && "$FPM_CWD" == "$START_FPM_CWD" && "$FPM_CGROUP" == "$START_FPM_CGROUP" ]] || return 1
  cp -- "/proc/${FPM_PID}/environ" "$ENV_DEST"; chmod 600 "$ENV_DEST"; [[ "$(hash_file "$ENV_DEST")" == "$ACTIVE_ENV_HASH" ]] || return 1
}
verify_lock || critical 'Lock identity changed immediately before reload'
[[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" && "$(stat -Lc '%d:%i:%u:%g:%a:%F' "$FASTCGI_SOCKET")" == "$SOCKET_BEFORE" ]] || critical 'PHP_INI or socket identity changed immediately before reload'
recheck_fpm_identity "${TX_DIR}/fpm.environment.pre-reload.nul" || critical 'FPM binary/argv/env/cwd/cgroup changed before reload'
collect_fpm_graph "${TX_DIR}/fpm-pre-reload" || critical 'FPM graph recheck failed'; cmp -s "${TX_DIR}/fpm-candidate.graph.tsv" "${TX_DIR}/fpm-pre-reload.graph.tsv" || critical 'FPM graph changed before reload'
capture_fpm_info "${TX_DIR}/php-pre-reload" || critical 'PHP graph recheck failed'; cmp -s "${TX_DIR}/php-candidate.php.graph.tsv" "${TX_DIR}/php-pre-reload.php.graph.tsv" || critical 'PHP graph changed before reload'
run_fpm -tt >/dev/null 2>&1 || recover_php_after_failure 'Final FPM -tt failed'
verify_lock || critical 'Lock identity changed after the final FPM test; no reload occurred'
[[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" && "$(stat -Lc '%d:%i:%u:%g:%a:%F' "$FASTCGI_SOCKET")" == "$SOCKET_BEFORE" ]] || critical 'PHP_INI or socket changed after the final FPM test; no reload occurred'
recheck_fpm_identity "${TX_DIR}/fpm.environment.reload-gate.nul" || critical 'FPM binary/argv/env/cwd/cgroup changed after the final test; no reload occurred'
mapfile -d '' -t MASTER_ENV <"${TX_DIR}/fpm.environment.reload-gate.nul"
collect_fpm_graph "${TX_DIR}/fpm-reload-gate" || critical 'Reload-gate FPM graph failed; no reload occurred'; cmp -s "${TX_DIR}/fpm-candidate.graph.tsv" "${TX_DIR}/fpm-reload-gate.graph.tsv" || critical 'FPM graph changed after the final test; no reload occurred'
capture_fpm_info "${TX_DIR}/php-reload-gate" || critical 'Reload-gate PHP graph failed; no reload occurred'; cmp -s "${TX_DIR}/php-candidate.php.graph.tsv" "${TX_DIR}/php-reload-gate.php.graph.tsv" || critical 'PHP graph changed after the final test; no reload occurred'
if ! systemctl reload "$PHP_FPM_UNIT"; then
  echo "Reload failed: disk hash was ${CANDIDATE_HASH}; FPM master/worker memory is unknown and may still use prior settings." >&2
  systemctl status "$PHP_FPM_UNIT" --no-pager || true; journalctl --unit="$PHP_FPM_UNIT" --since='-10 minutes' --no-pager || true
  critical 'Keep SSH and rescue artifacts; do not restart blindly. Inspect logs, then use the reviewed rollback with this TX_DIR or retry only after an exact successful test'
fi
systemctl is-active --quiet "$PHP_FPM_UNIT" || critical 'Reload returned success but the exact FPM unit is not active'
recheck_fpm_identity "${TX_DIR}/fpm.environment.after-reload.nul" || critical 'FPM binary/argv/env/cwd/cgroup changed after reload'
mapfile -d '' -t MASTER_ENV <"${TX_DIR}/fpm.environment.after-reload.nul"
[[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" ]] || critical 'PHP_INI changed after reload'
collect_fpm_graph "${TX_DIR}/fpm-after" || critical 'Post-reload FPM graph failed'; cmp -s "${TX_DIR}/fpm-candidate.graph.tsv" "${TX_DIR}/fpm-after.graph.tsv" || critical 'Post-reload FPM graph differs'
capture_fpm_info "${TX_DIR}/php-after" || critical 'Post-reload FPM -i identity failed'; cmp -s "${TX_DIR}/php-candidate.php.graph.tsv" "${TX_DIR}/php-after.php.graph.tsv" || critical 'Post-reload PHP graph differs'
SOCKET_AFTER="$(stat -Lc '%d:%i:%u:%g:%a:%F' "$FASTCGI_SOCKET")"
AFTER_PROBE="${TX_DIR}/probe.after.json"; probe_fpm "$AFTER_PROBE" || critical 'Post-reload restricted probe did not tie PID/exe/cgroup/INI to the selected unit'
cmp -s "${TX_DIR}/php-after.scanned" "${AFTER_PROBE}.scanned" || critical 'Post-reload FPM -i scanned files differ from live probe'
jq -e --arg upload "$EXPECTED_UPLOAD" --arg post "$EXPECTED_POST" '.upload_max_filesize==$upload and .post_max_size==$post' "$AFTER_PROBE" >/dev/null || critical 'Live worker limits do not match the reviewed values'
jq -S --arg socket_after "$SOCKET_AFTER" --arg after_probe "$AFTER_PROBE" --arg after_probe_sha256 "$(hash_file "$AFTER_PROBE")" \
  --arg fpm_after_graph_sha256 "$(hash_file "${TX_DIR}/fpm-after.graph.tsv")" --arg php_after_graph_sha256 "$(hash_file "${TX_DIR}/php-after.php.graph.tsv")" \
  --arg environment_after_sha256 "$(hash_file "${TX_DIR}/fpm.environment.after-reload.nul")" \
  '. + {socket_after:$socket_after,after_probe:$after_probe,after_probe_sha256:$after_probe_sha256,fpm_after_graph_sha256:$fpm_after_graph_sha256,php_after_graph_sha256:$php_after_graph_sha256,environment_after_sha256:$environment_after_sha256}' \
  "$STATE" >"${STATE}.tmp"; chmod 600 "${STATE}.tmp"; mv -T -- "${STATE}.tmp" "$STATE"
printf 'Reload command succeeded; disk/config graphs and restricted worker probe match pool=%s socket=%s INI=%s. Transaction=%s\n' "$POOL_NAME" "$FASTCGI_SOCKET" "$PHP_INI" "$TX_DIR"

En Node.js o una API, revisa después el límite propio del framework, middleware o endpoint y sus logs. Si Nginx o PHP viven en contenedores, no edites su sistema de archivos efímero: cambia el archivo persistente o Compose, respáldalo, valida dentro del contenedor o imagen candidata y recrea solo el servicio identificado. Conserva la definición anterior para rollback; no conviertas un 413 en una recreación global.

6. Verifica la misma operación de extremo a extremo

El bloque ejecuta after y solo acepta el before.state.json protegido del mismo CASE_ID. Recalcula el hash de cada artefacto y de cada manifiesto, valida su esquema y exige igualdad byte-lógica del objeto de identidad: método, URL, campo, request, auth, script, unidad, binario, vector de prueba y configuración principal. Solo entonces compara 413 con 2xx. Aun así debes confirmar el resultado funcional y correlacionar los timestamps en borde, Nginx, PHP y aplicación; un 2xx aislado no demuestra que una importación terminó.

set -euo pipefail

die() { echo "ERROR: $*" >&2; exit 1; }
hash_file() { sha256sum -- "$1" | awk '{print $1}'; }
[[ "$EUID" -eq 0 ]] || die 'Run the reviewed comparison as root'

REPLAY_SCRIPT='/REPLACE/saved/replay-script'
BEFORE_STATE='/REPLACE/exact/before.state.json'
[[ "$REPLAY_SCRIPT" == /* && "$REPLAY_SCRIPT" != /REPLACE* ]] || die 'Replace REPLAY_SCRIPT'
[[ -f "$REPLAY_SCRIPT" && ! -L "$REPLAY_SCRIPT" && "$(stat -Lc '%u:%a' "$REPLAY_SCRIPT")" == '0:700' ]] || die 'Replay script must be a root-owned mode-700 regular file'
[[ "$BEFORE_STATE" == /* && "$BEFORE_STATE" != /REPLACE* ]] || die 'Replace BEFORE_STATE with the exact state printed by phase=before'

validate_state() {
  local STATE="$1" EXPECTED_PHASE="$2" MANIFEST EVIDENCE EXPECTED_MANIFEST_HASH EXPECTED_IDENTITY_HASH NAME EXPECTED ACTUAL
  [[ -f "$STATE" && ! -L "$STATE" && "$(stat -Lc '%u:%a' "$STATE")" == '0:600' ]] || die "Unsafe state file: $STATE"
  jq -e --arg phase "$EXPECTED_PHASE" '
    (keys|sort)==["case_id","evidence_dir","identity_sha256","manifest","manifest_sha256","phase"] and
    .phase==$phase and (.case_id|type)=="string" and (.case_id|length)>0 and
    (.evidence_dir|type)=="string" and (.manifest|type)=="string" and
    (.manifest_sha256|test("^[0-9a-f]{64}$")) and (.identity_sha256|test("^[0-9a-f]{64}$"))
  ' "$STATE" >/dev/null || die "State schema or phase is invalid: $STATE"
  EVIDENCE="$(jq -er '.evidence_dir' "$STATE")"; MANIFEST="$(jq -er '.manifest' "$STATE")"
  [[ "$EVIDENCE" == /root/nginx-413-* && -d "$EVIDENCE" && ! -L "$EVIDENCE" && "$(stat -Lc '%u:%a' "$EVIDENCE")" == '0:700' ]] || die "Unsafe evidence directory: $EVIDENCE"
  [[ "$MANIFEST" == "${EVIDENCE}/manifest.json" && -f "$MANIFEST" && ! -L "$MANIFEST" && "$(stat -Lc '%u:%a' "$MANIFEST")" == '0:600' ]] || die 'State does not point to its protected manifest'
  EXPECTED_MANIFEST_HASH="$(jq -er '.manifest_sha256' "$STATE")"; [[ "$(hash_file "$MANIFEST")" == "$EXPECTED_MANIFEST_HASH" ]] || die 'Manifest hash differs from state'
  jq -e --arg phase "$EXPECTED_PHASE" '
    (keys|sort)==["artifacts","ended_at","http_status","identity","phase","started_at"] and .phase==$phase and
    (.http_status|test("^[0-9]{3}$")) and (.artifacts|type)=="object" and
    (.identity|keys|sort)==["auth_sha256","case_id","execstart_sha256","main_config","method","multipart_field","nginx_executable","nginx_unit","request_sha256","script_sha256","test_argv_b64","test_argv_sha256","url"] and
    all(.identity.request_sha256,.identity.auth_sha256,.identity.script_sha256,.identity.execstart_sha256,.identity.test_argv_sha256;test("^[0-9a-f]{64}$")) and
    .identity.request_sha256==.artifacts["request.bin"] and .identity.auth_sha256==.artifacts["curl-auth.conf"] and
    .identity.script_sha256==.artifacts["replay-script.snapshot"] and .identity.execstart_sha256==.artifacts["execstart.json"] and
    .identity.test_argv_sha256==.artifacts["nginx-test.argv.nul"]
  ' "$MANIFEST" >/dev/null || die 'Manifest schema is not exact'
  EXPECTED_IDENTITY_HASH="$(jq -er '.identity_sha256' "$STATE")"; [[ "$(jq -cS '.identity' "$MANIFEST" | sha256sum | awk '{print $1}')" == "$EXPECTED_IDENTITY_HASH" ]] || die 'Manifest identity hash differs from state'
  while IFS=$'\t' read -r NAME EXPECTED; do
    [[ "$NAME" =~ ^[a-zA-Z0-9._-]+$ && "$EXPECTED" =~ ^[0-9a-f]{64}$ ]] || die 'Unsafe artifact entry in manifest'
    [[ -f "${EVIDENCE}/${NAME}" && ! -L "${EVIDENCE}/${NAME}" && "$(stat -Lc '%u:%a' "${EVIDENCE}/${NAME}")" == '0:600' ]] || die "Unsafe or missing artifact: ${EVIDENCE}/${NAME}"
    ACTUAL="$(hash_file "${EVIDENCE}/${NAME}")"; [[ "$ACTUAL" == "$EXPECTED" ]] || die "Artifact hash mismatch: ${EVIDENCE}/${NAME}"
  done < <(jq -r '.artifacts|to_entries[]|[.key,.value]|@tsv' "$MANIFEST")
  for REQUIRED in request.bin curl-auth.conf replay-script.snapshot execstart.json nginx-test.argv.nul nginx-T.txt nginx-inventory.txt response.headers response.body response.status nginx.journal; do
    jq -e --arg name "$REQUIRED" '.artifacts|has($name)' "$MANIFEST" >/dev/null || die "Manifest lacks required artifact: $REQUIRED"
  done
  [[ "$(hash_file "$REPLAY_SCRIPT")" == "$(jq -er '.identity.script_sha256' "$MANIFEST")" ]] || die 'Current replay script differs from manifested script bytes'
  [[ "$(<"${EVIDENCE}/response.status")" == "$(jq -er '.http_status' "$MANIFEST")" ]] || die 'Status artifact differs from manifest'
  printf '%s\t%s\n' "$MANIFEST" "$EVIDENCE"
}

BEFORE_INFO="$(validate_state "$BEFORE_STATE" before)"
BEFORE_MANIFEST="${BEFORE_INFO%%$'\t'*}"; BEFORE_EVIDENCE="${BEFORE_INFO#*$'\t'}"
CASE_ID="$(jq -er '.case_id' "$BEFORE_STATE")"
[[ "$(jq -er '.identity.case_id' "$BEFORE_MANIFEST")" == "$CASE_ID" ]] || die 'Before state and manifest CASE_ID differ'

RESULT_FILE="$(mktemp --tmpdir=/root .nginx-413-after-result.XXXXXXXX)"; chmod 600 "$RESULT_FILE"
"$REPLAY_SCRIPT" after | tee "$RESULT_FILE"
AFTER_STATE="$(awk '/^phase=after /{for(i=1;i<=NF;i++)if($i~/^state=/){sub(/^state=/,"",$i);v=$i}}END{print v}' "$RESULT_FILE")"
[[ "$AFTER_STATE" == "/root/nginx-413-${CASE_ID}-after.state.json" ]] || die 'Replay did not publish the expected after state for this CASE_ID'
AFTER_INFO="$(validate_state "$AFTER_STATE" after)"
AFTER_MANIFEST="${AFTER_INFO%%$'\t'*}"; AFTER_EVIDENCE="${AFTER_INFO#*$'\t'}"
[[ "$(jq -er '.case_id' "$AFTER_STATE")" == "$CASE_ID" && "$(jq -er '.identity.case_id' "$AFTER_MANIFEST")" == "$CASE_ID" ]] || die 'After CASE_ID differs from baseline'

cmp -s <(jq -cS '.identity' "$BEFORE_MANIFEST") <(jq -cS '.identity' "$AFTER_MANIFEST") || die 'Before/after manifest identities are not exactly equal'
[[ "$(jq -er '.identity_sha256' "$BEFORE_STATE")" == "$(jq -er '.identity_sha256' "$AFTER_STATE")" ]] || die 'Before/after identity hashes differ'
BEFORE_STATUS="$(jq -er '.http_status' "$BEFORE_MANIFEST")"; AFTER_STATUS="$(jq -er '.http_status' "$AFTER_MANIFEST")"
[[ "$BEFORE_STATUS" == 413 ]] || die "Baseline was HTTP ${BEFORE_STATUS}, not 413; do not attribute a fix"
[[ "$AFTER_STATUS" =~ ^2[0-9][0-9]$ ]] || die "Exactly identified replay returned HTTP ${AFTER_STATUS}; inspect every layer"

echo 'WARNING: manifests, auth snapshots, headers, bodies, config dumps, and logs are sensitive. Redact copies before sharing.' >&2
printf 'Manifest-linked replay changed HTTP %s -> %s for exact identity %s. Verify the application result and matching edge/Nginx/PHP/application timestamps manually.\n' "$BEFORE_STATUS" "$AFTER_STATUS" "$(jq -er '.identity_sha256' "$BEFORE_STATE")"
printf 'before=%s after=%s\n' "$BEFORE_EVIDENCE" "$AFTER_EVIDENCE"

7. Rollback exacto si el cambio no funciona

Usa el TX_DIR exacto impreso por la transacción; nunca busques “el backup más reciente”. El rollback Nginx vuelve a comprobar lock FD/inode, ExecStart estructurado, vector completo, grafo de includes, hashes persistentes y bytes candidato. Guarda los bytes actuales, prepara stages root-owned en el mismo filesystem y usa rename atómico. Si el backup no pasa la prueba con el mismo contexto, reinstala atómicamente los bytes actuales, verifica el hash y repite el test. Todo fallo imprime CRITICAL non-convergent con rutas de rescate y no afirma que el servicio haya convergido.

set -euo pipefail

critical() {
  echo "CRITICAL non-convergent: $*" >&2
  echo "Rescue paths: TX_DIR=${TX_DIR:-unknown} STATE=${STATE:-unknown} CONFIG=${CONFIG:-unknown} BACKUP=${BACKUP:-unknown} ROLLBACK_DIR=${ROLLBACK_DIR:-unknown} CURRENT_COPY=${CURRENT_COPY:-unknown}" >&2
  exit 1
}
hash_file() { sha256sum -- "$1" | awk '{print $1}'; }
[[ "$EUID" -eq 0 ]] || critical 'Run the reviewed rollback as root'

LOCK_DIR='/run/teramont-nginx-413'
LOCKFILE="${LOCK_DIR}/transaction.lock"
verify_lock_dir() {
  [[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" && "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || return 1
  [[ -n "${LOCK_DIR_ID:-}" && "$(stat -Lc '%d:%i' -- "$LOCK_DIR")" == "$LOCK_DIR_ID" ]]
}
verify_lock() {
  local FD_PATH="/proc/$$/fd/${LOCK_FD}" FD_ID PATH_ID FD_META PATH_META
  verify_lock_dir || return 1; [[ -f "$FD_PATH" && -f "$LOCKFILE" && ! -L "$LOCKFILE" ]] || return 1
  FD_ID="$(stat -Lc '%d:%i' -- "$FD_PATH")" || return 1; PATH_ID="$(stat -Lc '%d:%i' -- "$LOCKFILE")" || return 1
  FD_META="$(stat -Lc '%u:%a:%h' -- "$FD_PATH")" || return 1; PATH_META="$(stat -Lc '%u:%a:%h' -- "$LOCKFILE")" || return 1
  [[ "$FD_ID" == "$PATH_ID" && "$FD_META" == '0:600:1' && "$PATH_META" == "$FD_META" ]]
}
prepare_lock_dir() {
  umask 077
  if [[ -e "$LOCK_DIR" || -L "$LOCK_DIR" ]]; then
    [[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" && "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || critical 'Unsafe dedicated Nginx lock directory'
  else
    mkdir --mode=700 -- "$LOCK_DIR" || critical 'Cannot create the dedicated Nginx lock directory'
  fi
  [[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" && "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || critical 'Dedicated Nginx lock directory failed validation'
  LOCK_DIR_ID="$(stat -Lc '%d:%i' -- "$LOCK_DIR")" || critical 'Cannot identify the dedicated Nginx lock directory'; verify_lock_dir || critical 'Dedicated Nginx lock directory identity changed'
}
acquire_lock() {
  local FD_PATH FD_ID PATH_ID FD_META PATH_META
  prepare_lock_dir
  if [[ -e "$LOCKFILE" || -L "$LOCKFILE" ]]; then
    [[ -f "$LOCKFILE" && ! -L "$LOCKFILE" && "$(stat -Lc '%u:%h' -- "$LOCKFILE")" == '0:1' ]] || critical 'Unsafe cooperative Nginx lock path'
  fi
  exec {LOCK_FD}>>"$LOCKFILE" || critical 'Cannot open the cooperative Nginx lock without truncation'
  FD_PATH="/proc/$$/fd/${LOCK_FD}"; [[ -f "$FD_PATH" && -f "$LOCKFILE" && ! -L "$LOCKFILE" ]] || critical 'Nginx lock type changed during open'
  FD_ID="$(stat -Lc '%d:%i' -- "$FD_PATH")"; PATH_ID="$(stat -Lc '%d:%i' -- "$LOCKFILE")"; FD_META="$(stat -Lc '%u:%h' -- "$FD_PATH")"; PATH_META="$(stat -Lc '%u:%h' -- "$LOCKFILE")"
  [[ "$FD_ID" == "$PATH_ID" && "$FD_META" == '0:1' && "$PATH_META" == "$FD_META" ]] || critical 'Nginx lock FD/path identity, owner, or link count is unsafe before chmod'
  chmod 600 -- "$FD_PATH" || critical 'Cannot set Nginx lock mode through its open descriptor'; verify_lock || critical 'Nginx lock identity is unsafe after descriptor chmod'
  flock --exclusive --nonblock "$LOCK_FD" || critical 'Another cooperating Nginx transaction holds the lock'; verify_lock || critical 'Nginx lock identity changed after flock'
}
lock_self_test() {
  local TEST_ROOT TEST_DIR TEST_LOCK VICTIM BEFORE_HASH BEFORE_MODE AFTER_HASH AFTER_MODE
  TEST_ROOT="$(mktemp -d /tmp/teramont-nginx-rollback-lock-test.XXXXXXXX)" || critical 'Cannot create rollback lock self-test directory'
  chmod 700 -- "$TEST_ROOT"; TEST_DIR="${TEST_ROOT}/lockdir"; TEST_LOCK="${TEST_DIR}/transaction.lock"; VICTIM="${TEST_ROOT}/victim"
  mkdir --mode=700 -- "$TEST_DIR"; printf 'DO-NOT-CHANGE\n' >"$VICTIM"; chmod 640 -- "$VICTIM"; BEFORE_HASH="$(hash_file "$VICTIM")"; BEFORE_MODE="$(stat -Lc '%a' -- "$VICTIM")"; ln -s -- "$VICTIM" "$TEST_LOCK"
  if (LOCK_DIR="$TEST_DIR"; LOCKFILE="$TEST_LOCK"; unset LOCK_DIR_ID LOCK_FD; acquire_lock) >/dev/null 2>&1; then critical 'LOCK_SELF_TEST failed: a symlink lock was accepted'; fi
  AFTER_HASH="$(hash_file "$VICTIM")"; AFTER_MODE="$(stat -Lc '%a' -- "$VICTIM")"; [[ "$AFTER_HASH" == "$BEFORE_HASH" && "$AFTER_MODE" == "$BEFORE_MODE" ]] || critical 'LOCK_SELF_TEST failed: victim bytes or mode changed'
  unlink -- "$TEST_LOCK"; unlink -- "$VICTIM"; rmdir -- "$TEST_DIR" "$TEST_ROOT"; printf 'LOCK_SELF_TEST=PASS script_aborted=1 victim_hash_before=%s victim_hash_after=%s victim_mode_before=%s victim_mode_after=%s\n' "$BEFORE_HASH" "$AFTER_HASH" "$BEFORE_MODE" "$AFTER_MODE"
}
if [[ "${1:-}" == '--lock-self-test' ]]; then lock_self_test; exit 0; fi

TX_DIR='/REPLACE/exact/nginx-transaction-directory'
CONFIG_MANAGER_STATE='REPLACE_PAUSED_OR_NONE'
[[ "$TX_DIR" == /* && "$TX_DIR" != /REPLACE* && -d "$TX_DIR" && ! -L "$TX_DIR" && "$(stat -Lc '%u:%a' "$TX_DIR")" == '0:700' ]] || critical 'Replace TX_DIR with the protected directory printed by the change flow'
case "$CONFIG_MANAGER_STATE" in PAUSED|NONE_CONFIRMED) ;; *) critical 'Pause the config manager/deployer, or prove none controls this graph' ;; esac
STATE="${TX_DIR}/transaction.json"
[[ -f "$STATE" && ! -L "$STATE" && "$(stat -Lc '%u:%a' "$STATE")" == '0:600' ]] || critical 'Protected transaction state is invalid'
jq -e 'has("unit") and has("config") and has("main_config") and has("nginx_bin") and has("execstart_sha256") and has("argv_sha256") and has("base_hash") and has("candidate_hash") and has("backup") and has("backup_sha") and has("lock_dir") and has("lockfile")' "$STATE" >/dev/null || critical 'Transaction state schema is incomplete'
json_value() { jq -er --arg key "$1" '.[$key]' "$STATE"; }
UNIT="$(json_value unit)"; CONFIG="$(json_value config)"; NGINX_CONF="$(json_value main_config)"; NGINX_BIN_EXPECTED="$(json_value nginx_bin)"
EXEC_HASH_EXPECTED="$(json_value execstart_sha256)"; ARGV_HASH_EXPECTED="$(json_value argv_sha256)"; BASE_HASH="$(json_value base_hash)"; CANDIDATE_HASH="$(json_value candidate_hash)"
BASE_GRAPH_HASH_EXPECTED="$(json_value base_graph_hash)"; CANDIDATE_GRAPH_HASH_EXPECTED="$(json_value candidate_graph_hash)"
BACKUP="$(json_value backup)"; BACKUP_SHA="$(json_value backup_sha)"; RECORDED_LOCK_DIR="$(json_value lock_dir)"; RECORDED_LOCKFILE="$(json_value lockfile)"; BASE_UID="$(json_value base_uid)"; BASE_GID="$(json_value base_gid)"; BASE_MODE="$(json_value base_mode)"
[[ "$RECORDED_LOCK_DIR" == "$LOCK_DIR" && "$RECORDED_LOCKFILE" == "$LOCKFILE" ]] || critical 'Recorded lock directory/path differs from the reviewed rollback'
[[ "$BASE_UID" == 0 ]] || critical 'Recorded CONFIG ownership is not root'
[[ "$(json_value config_manager_state)" == "$CONFIG_MANAGER_STATE" ]] || critical 'Config-manager state differs from the recorded transaction'
[[ "$CONFIG" == /* && "$BACKUP" == "${TX_DIR}/"* && "$BACKUP_SHA" == "${TX_DIR}/"* ]] || critical 'Transaction paths do not belong together'
for FILE in "$CONFIG" "$BACKUP" "$BACKUP_SHA" "${TX_DIR}/base.graph.tsv" "${TX_DIR}/candidate.graph.tsv"; do [[ -f "$FILE" && ! -L "$FILE" ]] || critical "Required transaction file changed type: $FILE"; done
sha256sum --check "$BACKUP_SHA" || critical 'Persistent base backup SHA failed'; [[ "$(hash_file "$BACKUP")" == "$BASE_HASH" ]] || critical 'Backup hash differs from transaction state'
[[ "$(hash_file "${TX_DIR}/base.graph.tsv")" == "$BASE_GRAPH_HASH_EXPECTED" && "$(hash_file "${TX_DIR}/candidate.graph.tsv")" == "$CANDIDATE_GRAPH_HASH_EXPECTED" ]] || critical 'Recorded Nginx graph hashes differ from transaction state'

get_unit_path() {
  local REPLY
  REPLY="$(busctl --json=short call org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager GetUnit s "$UNIT")" || critical 'D-Bus GetUnit failed'
  UNIT_PATH="$(jq -er 'select(.type=="o") | if (.data|type)=="string" then .data elif ((.data|type)=="array" and (.data|length)==1 and (.data[0]|type)=="string") then .data[0] else error("ambiguous") end' <<<"$REPLY")" || critical 'Cannot decode the unit path; stop and consult the administrator'
}
capture_identity() {
  local EXEC_PATH ARGV0 PROC_BIN
  get_unit_path
  EXEC_JSON="$(busctl --json=short get-property org.freedesktop.systemd1 "$UNIT_PATH" org.freedesktop.systemd1.Service ExecStart)" || critical 'Cannot read structured ExecStart'
  jq -e '.type=="a(sasbttttuii)" and (.data|type)=="array" and (.data|length)==1 and (.data[0]|length)==10 and (.data[0][1]|type)=="array" and (.data[0][1]|length)>=1 and all(.data[0][1][];type=="string")' <<<"$EXEC_JSON" >/dev/null || critical 'ExecStart is ambiguous; stop and consult the administrator'
  mapfile -d '' -t EXEC_ARGV < <(jq -j '.data[0][1][] | ., "\u0000"' <<<"$EXEC_JSON")
  EXEC_PATH="$(jq -er '.data[0][0]' <<<"$EXEC_JSON")"; NGINX_BIN="$(readlink -f -- "$EXEC_PATH")"; ARGV0="$(readlink -f -- "${EXEC_ARGV[0]}")"
  MAINPID="$(systemctl show --property=MainPID --value "$UNIT")"; [[ "$MAINPID" =~ ^[1-9][0-9]*$ ]] || critical 'Nginx MainPID is unavailable'; PROC_BIN="$(readlink -f -- "/proc/${MAINPID}/exe")"
  [[ "$NGINX_BIN" == "$ARGV0" && "$NGINX_BIN" == "$PROC_BIN" && "$NGINX_BIN" == "$NGINX_BIN_EXPECTED" ]] || critical 'Nginx binary identity changed'
  NGINX_CONTEXT=("${EXEC_ARGV[@]:1}")
  for ARG in "${NGINX_CONTEXT[@]}"; do case "$ARG" in -s|-s*|-t|-T|-q|-v|-V|-h|-\?) critical 'ExecStart contains an action flag unsafe to reuse' ;; esac; done
  EXEC_HASH="$(jq -cS . <<<"$EXEC_JSON" | sha256sum | awk '{print $1}')"; ARGV_HASH="$({ printf '%s\0' "${EXEC_ARGV[@]}"; } | sha256sum | awk '{print $1}')"
  [[ "$EXEC_HASH" == "$EXEC_HASH_EXPECTED" && "$ARGV_HASH" == "$ARGV_HASH_EXPECTED" ]] || critical 'Structured ExecStart vector changed since the transaction'
}
run_test() { "$NGINX_BIN" -t "${NGINX_CONTEXT[@]}"; }
make_graph() {
  local PREFIX="$1" FILE
  "$NGINX_BIN" -T "${NGINX_CONTEXT[@]}" >"${PREFIX}.dump" || return 1
  awk '/^# configuration file /{v=$0;sub(/^# configuration file /,"",v);sub(/:$/,"",v);print v}' "${PREFIX}.dump" | LC_ALL=C sort -u >"${PREFIX}.paths"; [[ -s "${PREFIX}.paths" ]] || return 1
  : >"${PREFIX}.graph.tsv"; while IFS= read -r FILE; do [[ "$FILE" == /* && -f "$FILE" && ! -L "$FILE" ]] || return 1; printf '%s\t%s\n' "$FILE" "$(hash_file "$FILE")" >>"${PREFIX}.graph.tsv"; done <"${PREFIX}.paths"
  chmod 600 "${PREFIX}.dump" "${PREFIX}.paths" "${PREFIX}.graph.tsv"
}
systemctl is-active --quiet "$UNIT" || critical 'The exact Nginx unit is not active'
capture_identity
acquire_lock
[[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || critical 'CONFIG no longer has the recorded candidate hash; no bytes were overwritten'
make_graph "${TX_DIR}/rollback-current" || critical 'Cannot capture current graph'; cmp -s "${TX_DIR}/candidate.graph.tsv" "${TX_DIR}/rollback-current.graph.tsv" || critical 'Current graph differs from the tested candidate graph; no bytes were overwritten'

CONFIG_DIR="$(dirname -- "$CONFIG")"; ROLLBACK_DIR="$(mktemp -d --tmpdir="$CONFIG_DIR" .nginx-413-rollback.XXXXXXXX)"; chmod 700 "$ROLLBACK_DIR"
[[ "$(stat -Lc '%d:%u:%a' "$ROLLBACK_DIR")" == "$(stat -Lc '%d' "$CONFIG_DIR"):0:700" ]] || critical 'Rollback directory is not protected on CONFIG filesystem'
CURRENT_COPY="${ROLLBACK_DIR}/config.current"; CURRENT_SHA="${ROLLBACK_DIR}/config.current.sha256"
cp --archive -- "$CONFIG" "$CURRENT_COPY" || critical 'Could not preserve current bytes'; [[ "$(hash_file "$CURRENT_COPY")" == "$CANDIDATE_HASH" ]] || critical 'Current rescue hash mismatch'
printf '%s  %s\n' "$CANDIDATE_HASH" "$CURRENT_COPY" >"$CURRENT_SHA"; chmod 600 "$CURRENT_SHA"; sha256sum --check "$CURRENT_SHA" || critical 'Persistent current SHA failed'

install_current_after_base_failure() {
  local REASON="$1" STAGE="${ROLLBACK_DIR}/config.current.restore.stage"
  cp --archive -- "$CURRENT_COPY" "$STAGE" || critical "$REASON; current bytes could not be staged"; chown "$BASE_UID:$BASE_GID" "$STAGE"; chmod "$BASE_MODE" "$STAGE"
  [[ "$(hash_file "$STAGE")" == "$CANDIDATE_HASH" && "$(stat -Lc '%d' "$STAGE")" == "$(stat -Lc '%d' "$CONFIG")" ]] || critical "$REASON; current stage mismatch"
  mv -T -- "$STAGE" "$CONFIG" || critical "$REASON; atomic current rename failed"; [[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || critical "$REASON; current hash is not on disk"
  run_test || critical "$REASON; base failed and repeated current test also failed; no reload occurred"
  critical "$REASON; current hash/test and rescue paths are recorded; no reload occurred"
}

BASE_STAGE="${ROLLBACK_DIR}/config.base.stage"; cp --archive -- "$BACKUP" "$BASE_STAGE" || critical 'Could not stage base backup'; chown "$BASE_UID:$BASE_GID" "$BASE_STAGE"; chmod "$BASE_MODE" "$BASE_STAGE"
[[ "$(hash_file "$BASE_STAGE")" == "$BASE_HASH" && "$(stat -Lc '%d' "$BASE_STAGE")" == "$(stat -Lc '%d' "$CONFIG")" ]] || critical 'Base stage hash/filesystem mismatch'
verify_lock || critical 'Lock changed before atomic rollback'; [[ "$(hash_file "$CONFIG")" == "$CANDIDATE_HASH" ]] || critical 'CONFIG changed before atomic rollback'
unexpected_after_mutation() { local RC=$?; trap - ERR; critical "unexpected command failure after rollback mutation (rc=${RC}, line=${BASH_LINENO[0]:-unknown}); state was not assumed"; }
trap unexpected_after_mutation ERR
mv -T -- "$BASE_STAGE" "$CONFIG" || critical 'Atomic base rename failed'; [[ "$(hash_file "$CONFIG")" == "$BASE_HASH" && "$(stat -Lc '%u:%g:%a' "$CONFIG")" == "${BASE_UID}:${BASE_GID}:${BASE_MODE}" ]] || critical 'Base disk hash or attributes mismatch'
run_test || install_current_after_base_failure 'Base nginx -t failed'
make_graph "${ROLLBACK_DIR}/base-tested" || install_current_after_base_failure 'Base nginx -T failed'
cmp -s "${TX_DIR}/base.graph.tsv" "${ROLLBACK_DIR}/base-tested.graph.tsv" || install_current_after_base_failure 'Base include graph does not match recorded graph'

verify_lock || critical 'Lock changed immediately before rollback reload'; [[ "$(hash_file "$CONFIG")" == "$BASE_HASH" ]] || critical 'CONFIG changed immediately before rollback reload'
capture_identity; make_graph "${ROLLBACK_DIR}/base-recheck" || critical 'Final base graph recheck failed; no reload occurred'; cmp -s "${TX_DIR}/base.graph.tsv" "${ROLLBACK_DIR}/base-recheck.graph.tsv" || critical 'Base graph changed before reload; no reload occurred'; run_test || install_current_after_base_failure 'Final base test failed'
verify_lock || critical 'Lock changed after the final base test; no reload occurred'; [[ "$(hash_file "$CONFIG")" == "$BASE_HASH" ]] || critical 'CONFIG changed after the final base test; no reload occurred'; capture_identity; make_graph "${ROLLBACK_DIR}/base-reload-gate" || critical 'Reload-gate base graph capture failed; no reload occurred'; cmp -s "${TX_DIR}/base.graph.tsv" "${ROLLBACK_DIR}/base-reload-gate.graph.tsv" || critical 'Base graph changed after the final test; no reload occurred'
if ! systemctl reload "$UNIT"; then
  echo "Reload failed: disk hash was ${BASE_HASH}; master/worker memory is unknown and may still use candidate settings." >&2
  systemctl status "$UNIT" --no-pager || true; journalctl --unit="$UNIT" --since='-10 minutes' --no-pager || true
  critical 'Do not restart blindly. Keep both rescue copies and SSH; inspect logs, then retry only after an exact successful test or atomically recover CURRENT_COPY'
fi
systemctl is-active --quiet "$UNIT" || critical 'Rollback reload returned success but the exact unit is not active'
capture_identity; [[ "$(hash_file "$CONFIG")" == "$BASE_HASH" ]] || critical 'CONFIG changed after rollback reload'
make_graph "${ROLLBACK_DIR}/after-rollback" || critical 'Post-rollback graph capture failed'; cmp -s "${TX_DIR}/base.graph.tsv" "${ROLLBACK_DIR}/after-rollback.graph.tsv" || critical 'Post-rollback graph differs from recorded base graph'
printf 'Rollback reload command succeeded; disk and post-check graph match base hash=%s. Retain rescue paths until the identical request and logs are verified.\n' "$BASE_HASH"

El rollback PHP hereda el contexto completo: binario, argv, entorno limpio, cwd, cgroup, socket, pool y grafos FPM/PHP. Después del reload compara la sonda local con el snapshot anterior, salvo su PID, y persiste un snapshot de rollback. Si un reload falla, ambos flujos distinguen los bytes conocidos en disco del estado desconocido de master/workers, muestran status y journal y piden conservar SSH y rescates; no recomiendan restart ni afirman convergencia.

set -euo pipefail

critical() {
  echo "CRITICAL non-convergent: $*" >&2
  echo "Rescue paths: TX_DIR=${TX_DIR:-unknown} STATE=${STATE:-unknown} PHP_INI=${PHP_INI:-unknown} BACKUP=${BACKUP:-unknown} ROLLBACK_DIR=${ROLLBACK_DIR:-unknown} CURRENT_COPY=${CURRENT_COPY:-unknown}" >&2
  exit 1
}
hash_file() { sha256sum -- "$1" | awk '{print $1}'; }
[[ "$EUID" -eq 0 ]] || critical 'Run the reviewed rollback as root'

LOCK_DIR='/run/teramont-php-413'
LOCKFILE="${LOCK_DIR}/transaction.lock"
verify_lock_dir() {
  [[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" && "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || return 1
  [[ -n "${LOCK_DIR_ID:-}" && "$(stat -Lc '%d:%i' -- "$LOCK_DIR")" == "$LOCK_DIR_ID" ]]
}
verify_lock() {
  local FD_PATH="/proc/$$/fd/${LOCK_FD}" FD_ID PATH_ID FD_META PATH_META
  verify_lock_dir || return 1; [[ -f "$FD_PATH" && -f "$LOCKFILE" && ! -L "$LOCKFILE" ]] || return 1
  FD_ID="$(stat -Lc '%d:%i' -- "$FD_PATH")" || return 1; PATH_ID="$(stat -Lc '%d:%i' -- "$LOCKFILE")" || return 1
  FD_META="$(stat -Lc '%u:%a:%h' -- "$FD_PATH")" || return 1; PATH_META="$(stat -Lc '%u:%a:%h' -- "$LOCKFILE")" || return 1
  [[ "$FD_ID" == "$PATH_ID" && "$FD_META" == '0:600:1' && "$PATH_META" == "$FD_META" ]]
}
prepare_lock_dir() {
  umask 077
  if [[ -e "$LOCK_DIR" || -L "$LOCK_DIR" ]]; then
    [[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" && "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || critical 'Unsafe dedicated FPM lock directory'
  else
    mkdir --mode=700 -- "$LOCK_DIR" || critical 'Cannot create the dedicated FPM lock directory'
  fi
  [[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" && "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || critical 'Dedicated FPM lock directory failed validation'
  LOCK_DIR_ID="$(stat -Lc '%d:%i' -- "$LOCK_DIR")" || critical 'Cannot identify the dedicated FPM lock directory'; verify_lock_dir || critical 'Dedicated FPM lock directory identity changed'
}
acquire_lock() {
  local FD_PATH FD_ID PATH_ID FD_META PATH_META
  prepare_lock_dir
  if [[ -e "$LOCKFILE" || -L "$LOCKFILE" ]]; then
    [[ -f "$LOCKFILE" && ! -L "$LOCKFILE" && "$(stat -Lc '%u:%h' -- "$LOCKFILE")" == '0:1' ]] || critical 'Unsafe cooperative FPM lock path'
  fi
  exec {LOCK_FD}>>"$LOCKFILE" || critical 'Cannot open the cooperative FPM lock without truncation'
  FD_PATH="/proc/$$/fd/${LOCK_FD}"; [[ -f "$FD_PATH" && -f "$LOCKFILE" && ! -L "$LOCKFILE" ]] || critical 'FPM lock type changed during open'
  FD_ID="$(stat -Lc '%d:%i' -- "$FD_PATH")"; PATH_ID="$(stat -Lc '%d:%i' -- "$LOCKFILE")"; FD_META="$(stat -Lc '%u:%h' -- "$FD_PATH")"; PATH_META="$(stat -Lc '%u:%h' -- "$LOCKFILE")"
  [[ "$FD_ID" == "$PATH_ID" && "$FD_META" == '0:1' && "$PATH_META" == "$FD_META" ]] || critical 'FPM lock FD/path identity, owner, or link count is unsafe before chmod'
  chmod 600 -- "$FD_PATH" || critical 'Cannot set FPM lock mode through its open descriptor'; verify_lock || critical 'FPM lock identity is unsafe after descriptor chmod'
  flock --exclusive --nonblock "$LOCK_FD" || critical 'Another cooperating FPM transaction holds the lock'; verify_lock || critical 'FPM lock identity changed after flock'
}
lock_self_test() {
  local TEST_ROOT TEST_DIR TEST_LOCK VICTIM BEFORE_HASH BEFORE_MODE AFTER_HASH AFTER_MODE
  TEST_ROOT="$(mktemp -d /tmp/teramont-php-rollback-lock-test.XXXXXXXX)" || critical 'Cannot create rollback lock self-test directory'
  chmod 700 -- "$TEST_ROOT"; TEST_DIR="${TEST_ROOT}/lockdir"; TEST_LOCK="${TEST_DIR}/transaction.lock"; VICTIM="${TEST_ROOT}/victim"
  mkdir --mode=700 -- "$TEST_DIR"; printf 'DO-NOT-CHANGE\n' >"$VICTIM"; chmod 640 -- "$VICTIM"; BEFORE_HASH="$(hash_file "$VICTIM")"; BEFORE_MODE="$(stat -Lc '%a' -- "$VICTIM")"; ln -s -- "$VICTIM" "$TEST_LOCK"
  if (LOCK_DIR="$TEST_DIR"; LOCKFILE="$TEST_LOCK"; unset LOCK_DIR_ID LOCK_FD; acquire_lock) >/dev/null 2>&1; then critical 'LOCK_SELF_TEST failed: a symlink lock was accepted'; fi
  AFTER_HASH="$(hash_file "$VICTIM")"; AFTER_MODE="$(stat -Lc '%a' -- "$VICTIM")"; [[ "$AFTER_HASH" == "$BEFORE_HASH" && "$AFTER_MODE" == "$BEFORE_MODE" ]] || critical 'LOCK_SELF_TEST failed: victim bytes or mode changed'
  unlink -- "$TEST_LOCK"; unlink -- "$VICTIM"; rmdir -- "$TEST_DIR" "$TEST_ROOT"; printf 'LOCK_SELF_TEST=PASS script_aborted=1 victim_hash_before=%s victim_hash_after=%s victim_mode_before=%s victim_mode_after=%s\n' "$BEFORE_HASH" "$AFTER_HASH" "$BEFORE_MODE" "$AFTER_MODE"
}
if [[ "${1:-}" == '--lock-self-test' ]]; then lock_self_test; exit 0; fi

TX_DIR='/REPLACE/exact/php-transaction-directory'
CONFIG_MANAGER_STATE='REPLACE_PAUSED_OR_NONE'
[[ "$TX_DIR" == /* && "$TX_DIR" != /REPLACE* && -d "$TX_DIR" && ! -L "$TX_DIR" && "$(stat -Lc '%u:%a' "$TX_DIR")" == '0:700' ]] || critical 'Replace TX_DIR with the protected PHP transaction directory'
case "$CONFIG_MANAGER_STATE" in PAUSED|NONE_CONFIRMED) ;; *) critical 'Pause the config manager/deployer, or prove none controls these files' ;; esac
STATE="${TX_DIR}/transaction.json"
[[ -f "$STATE" && ! -L "$STATE" && "$(stat -Lc '%u:%a' "$STATE")" == '0:600' ]] || critical 'Protected PHP transaction state is invalid'
jq -e 'has("unit") and has("fpm_config") and has("pool_config") and has("socket") and has("php_ini") and has("fpm_bin") and has("execstart_sha256") and has("argv_sha256") and has("environment_sha256") and has("cwd") and has("cgroup") and has("base_hash") and has("candidate_hash") and has("backup") and has("backup_sha") and has("lock_dir") and has("lockfile") and has("before_probe")' "$STATE" >/dev/null || critical 'Transaction state schema is incomplete'
json_value() { jq -er --arg key "$1" '.[$key]' "$STATE"; }
PHP_FPM_UNIT="$(json_value unit)"; FPM_CONFIG="$(json_value fpm_config)"; POOL_CONFIG="$(json_value pool_config)"; POOL_NAME="$(json_value pool_name)"; POOL_USER="$(json_value pool_user)"; POOL_GROUP="$(json_value pool_group)"
FASTCGI_SOCKET="$(json_value socket)"; PHP_INI="$(json_value php_ini)"; FPM_BIN_EXPECTED="$(json_value fpm_bin)"; EXEC_HASH_EXPECTED="$(json_value execstart_sha256)"; ARGV_HASH_EXPECTED="$(json_value argv_sha256)"
ENV_HASH_EXPECTED="$(json_value environment_sha256)"; CWD_EXPECTED="$(json_value cwd)"; CGROUP_EXPECTED="$(json_value cgroup)"; BASE_HASH="$(json_value base_hash)"; CANDIDATE_HASH="$(json_value candidate_hash)"
FPM_GRAPH_HASH_EXPECTED="$(json_value fpm_graph_hash)"; PHP_BASE_GRAPH_HASH_EXPECTED="$(json_value php_base_graph_hash)"; PHP_CANDIDATE_GRAPH_HASH_EXPECTED="$(json_value php_candidate_graph_hash)"
BACKUP="$(json_value backup)"; BACKUP_SHA="$(json_value backup_sha)"; RECORDED_LOCK_DIR="$(json_value lock_dir)"; RECORDED_LOCKFILE="$(json_value lockfile)"; BEFORE_PROBE="$(json_value before_probe)"; BEFORE_PROBE_SHA="$(json_value before_probe_sha256)"; CGI_FCGI_BIN="$(json_value cgi_fcgi)"
[[ "$RECORDED_LOCK_DIR" == "$LOCK_DIR" && "$RECORDED_LOCKFILE" == "$LOCKFILE" ]] || critical 'Recorded FPM lock directory/path differs from the reviewed rollback'
BASE_UID="$(json_value base_uid)"; BASE_GID="$(json_value base_gid)"; BASE_MODE="$(json_value base_mode)"
[[ "$BASE_UID" == 0 ]] || critical 'Recorded PHP_INI ownership is not root'
[[ "$(json_value config_manager_state)" == "$CONFIG_MANAGER_STATE" ]] || critical 'Config-manager state differs from the recorded transaction'
[[ "$PHP_INI" == /* && "$BACKUP" == "${TX_DIR}/"* && "$BACKUP_SHA" == "${TX_DIR}/"* && "$BEFORE_PROBE" == "${TX_DIR}/"* ]] || critical 'Transaction paths do not belong together'
for FILE in "$PHP_INI" "$FPM_CONFIG" "$POOL_CONFIG" "$BACKUP" "$BACKUP_SHA" "$BEFORE_PROBE" "${TX_DIR}/fpm-base.graph.tsv" "${TX_DIR}/php-base.php.graph.tsv" "${TX_DIR}/php-candidate.php.graph.tsv"; do [[ -f "$FILE" && ! -L "$FILE" ]] || critical "Required state file changed type: $FILE"; done
[[ -x "$CGI_FCGI_BIN" && -S "$FASTCGI_SOCKET" && ! -L "$FASTCGI_SOCKET" ]] || critical 'Probe client or recorded Unix socket is unavailable'
sha256sum --check "$BACKUP_SHA" || critical 'Persistent PHP base backup SHA failed'; [[ "$(hash_file "$BACKUP")" == "$BASE_HASH" ]] || critical 'Base backup hash differs from state'
[[ "$(hash_file "${TX_DIR}/fpm-base.graph.tsv")" == "$FPM_GRAPH_HASH_EXPECTED" && "$(hash_file "${TX_DIR}/php-base.php.graph.tsv")" == "$PHP_BASE_GRAPH_HASH_EXPECTED" && "$(hash_file "${TX_DIR}/php-candidate.php.graph.tsv")" == "$PHP_CANDIDATE_GRAPH_HASH_EXPECTED" ]] || critical 'Recorded FPM/PHP graph hashes differ from transaction state'
[[ "$(hash_file "$BEFORE_PROBE")" == "$BEFORE_PROBE_SHA" ]] || critical 'Protected before-probe hash differs from transaction state'
if jq -e 'has("after_probe")' "$STATE" >/dev/null; then
  RECORDED_AFTER_PROBE="$(json_value after_probe)"; RECORDED_AFTER_PROBE_SHA="$(json_value after_probe_sha256)"
  [[ "$RECORDED_AFTER_PROBE" == "${TX_DIR}/"* && -f "$RECORDED_AFTER_PROBE" && ! -L "$RECORDED_AFTER_PROBE" && "$(hash_file "$RECORDED_AFTER_PROBE")" == "$RECORDED_AFTER_PROBE_SHA" ]] || critical 'Protected after-probe artifact differs from transaction state'
fi

get_unit_path() {
  local REPLY
  REPLY="$(busctl --json=short call org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager GetUnit s "$PHP_FPM_UNIT")" || critical 'D-Bus GetUnit failed'
  UNIT_PATH="$(jq -er 'select(.type=="o") | if (.data|type)=="string" then .data elif ((.data|type)=="array" and (.data|length)==1 and (.data[0]|type)=="string") then .data[0] else error("ambiguous") end' <<<"$REPLY")" || critical 'Cannot decode the unit path; stop and consult the administrator'
}
capture_fpm() {
  local EXEC_PATH ARGV0 PROC_BIN MASTER_CGROUP
  get_unit_path
  EXEC_JSON="$(busctl --json=short get-property org.freedesktop.systemd1 "$UNIT_PATH" org.freedesktop.systemd1.Service ExecStart)" || critical 'Cannot read structured FPM ExecStart'
  jq -e '.type=="a(sasbttttuii)" and (.data|type)=="array" and (.data|length)==1 and (.data[0]|length)==10 and (.data[0][1]|type)=="array" and (.data[0][1]|length)>=1 and all(.data[0][1][];type=="string")' <<<"$EXEC_JSON" >/dev/null || critical 'FPM ExecStart is ambiguous; stop and consult the administrator'
  mapfile -d '' -t FPM_ARGV < <(jq -j '.data[0][1][] | ., "\u0000"' <<<"$EXEC_JSON")
  EXEC_PATH="$(jq -er '.data[0][0]' <<<"$EXEC_JSON")"; FPM_BIN="$(readlink -f -- "$EXEC_PATH")"; ARGV0="$(readlink -f -- "${FPM_ARGV[0]}")"
  FPM_PID="$(systemctl show --property=MainPID --value "$PHP_FPM_UNIT")"; [[ "$FPM_PID" =~ ^[1-9][0-9]*$ ]] || critical 'FPM MainPID is unavailable'; PROC_BIN="$(readlink -f -- "/proc/${FPM_PID}/exe")"
  [[ "$FPM_BIN" == "$ARGV0" && "$FPM_BIN" == "$PROC_BIN" && "$FPM_BIN" == "$FPM_BIN_EXPECTED" ]] || critical 'FPM binary identity changed'
  FPM_CONTEXT=("${FPM_ARGV[@]:1}")
  for ARG in "${FPM_CONTEXT[@]}"; do case "$ARG" in -t|-tt|-i|-m|-v|--test|--phpinfo|--modules|--version) critical 'FPM ExecStart contains an action flag unsafe to reuse' ;; esac; done
  EXEC_HASH="$(jq -cS . <<<"$EXEC_JSON" | sha256sum | awk '{print $1}')"; ARGV_HASH="$({ printf '%s\0' "${FPM_ARGV[@]}"; } | sha256sum | awk '{print $1}')"
  [[ "$EXEC_HASH" == "$EXEC_HASH_EXPECTED" && "$ARGV_HASH" == "$ARGV_HASH_EXPECTED" ]] || critical 'Structured FPM vector changed since transaction'
  FPM_CWD="$(readlink -f -- "/proc/${FPM_PID}/cwd")"; FPM_CGROUP="$(systemctl show --property=ControlGroup --value "$PHP_FPM_UNIT")"
  [[ "$FPM_CWD" == "$CWD_EXPECTED" && "$FPM_CGROUP" == "$CGROUP_EXPECTED" ]] || critical 'FPM cwd or ControlGroup changed'
  MASTER_CGROUP="$(awk -F: '$1=="0"{print $3}' "/proc/${FPM_PID}/cgroup")"; [[ "$MASTER_CGROUP" == "$FPM_CGROUP" || "$MASTER_CGROUP" == "$FPM_CGROUP"/* ]] || critical 'FPM MainPID is outside its ControlGroup'
}
load_master_environment() {
  ACTIVE_ENV_FILE="$1"; cp -- "/proc/${FPM_PID}/environ" "$ACTIVE_ENV_FILE"; chmod 600 "$ACTIVE_ENV_FILE"; [[ "$(hash_file "$ACTIVE_ENV_FILE")" == "$ENV_HASH_EXPECTED" ]] || critical 'FPM master environment changed'
  mapfile -d '' -t MASTER_ENV <"$ACTIVE_ENV_FILE"; for ENTRY in "${MASTER_ENV[@]}"; do NAME="${ENTRY%%=*}"; [[ "$ENTRY" == *=* && "$NAME" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || critical 'Unsafe environment entry'; done
}
run_fpm() { (cd "$FPM_CWD" && env -i "${MASTER_ENV[@]}" "$FPM_BIN" "${FPM_CONTEXT[@]}" "$@"); }
collect_fpm_graph() {
  local PREFIX="$1" CURRENT PATTERN MATCH; declare -A SEEN=(); local -a QUEUE=("$FPM_CONFIG") MATCHES=(); : >"${PREFIX}.paths"
  while ((${#QUEUE[@]})); do CURRENT="${QUEUE[0]}"; QUEUE=("${QUEUE[@]:1}"); [[ "$CURRENT" == /* && -f "$CURRENT" && ! -L "$CURRENT" ]] || return 1; CURRENT="$(readlink -f -- "$CURRENT")"; [[ -z "${SEEN[$CURRENT]:-}" ]] || continue; SEEN[$CURRENT]=1; printf '%s\n' "$CURRENT" >>"${PREFIX}.paths"; while IFS= read -r PATTERN; do [[ "$PATTERN" == /* && "$PATTERN" != *' '* ]] || return 1; mapfile -t MATCHES < <(compgen -G "$PATTERN" | LC_ALL=C sort); ((${#MATCHES[@]})) || return 1; for MATCH in "${MATCHES[@]}"; do QUEUE+=("$MATCH"); done; done < <(awk -F= '/^[[:space:]]*include[[:space:]]*=/{v=$0;sub(/^[^=]*=/,"",v);gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v}' "$CURRENT"); done
  LC_ALL=C sort -u -o "${PREFIX}.paths" "${PREFIX}.paths"; : >"${PREFIX}.graph.tsv"; while IFS= read -r CURRENT; do printf '%s\t%s\n' "$CURRENT" "$(hash_file "$CURRENT")" >>"${PREFIX}.graph.tsv"; done <"${PREFIX}.paths"; chmod 600 "${PREFIX}.paths" "${PREFIX}.graph.tsv"
}
normalize_ini_list() {
  local RAW="$1" DEST="$2" ITEM; : >"$DEST"
  while IFS= read -r ITEM; do ITEM="$(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' <<<"$ITEM")"; [[ -z "$ITEM" || "$ITEM" == '(none)' ]] && continue; [[ "$ITEM" == /* && -f "$ITEM" && ! -L "$ITEM" ]] || return 1; readlink -f -- "$ITEM" >>"$DEST"; done < <(printf '%s\n' "$RAW" | tr ',' '\n'); chmod 600 "$DEST"
}
capture_php_graph() {
  local PREFIX="$1" RAW; run_fpm -i >"${PREFIX}.info" || return 1; chmod 600 "${PREFIX}.info"; grep -Fq 'Server API => FPM/FastCGI' "${PREFIX}.info" || return 1
  mapfile -t LOADED < <(awk -F ' => ' '$1=="Loaded Configuration File"{print $2}' "${PREFIX}.info"); [[ ${#LOADED[@]} == 1 && "$(readlink -f -- "${LOADED[0]}")" == "$PHP_INI" ]] || return 1
  RAW="$(awk '/^Additional .ini files parsed => /{active=1;sub(/^Additional .ini files parsed => /,"");print;next} active&&/^[^[:space:]].* => /{exit} active{print}' "${PREFIX}.info")"; normalize_ini_list "$RAW" "${PREFIX}.scanned" || return 1
  { printf '%s\n' "$PHP_INI"; cat "${PREFIX}.scanned"; } | LC_ALL=C sort -u >"${PREFIX}.php.paths"; : >"${PREFIX}.php.graph.tsv"; while IFS= read -r FILE; do printf '%s\t%s\n' "$FILE" "$(hash_file "$FILE")" >>"${PREFIX}.php.graph.tsv"; done <"${PREFIX}.php.paths"; chmod 600 "${PREFIX}.php.paths" "${PREFIX}.php.graph.tsv"
}
systemctl is-active --quiet "$PHP_FPM_UNIT" || critical 'The exact FPM unit is not active'; capture_fpm
acquire_lock
ROLLBACK_DIR="$(mktemp -d --tmpdir="$(dirname -- "$PHP_INI")" .php-413-rollback.XXXXXXXX)"; chmod 700 "$ROLLBACK_DIR"; [[ "$(stat -Lc '%d:%u:%a' "$ROLLBACK_DIR")" == "$(stat -Lc '%d' "$(dirname -- "$PHP_INI")"):0:700" ]] || critical 'Rollback directory is not protected on PHP_INI filesystem'
load_master_environment "${ROLLBACK_DIR}/fpm.environment.current.nul"
[[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" ]] || critical 'PHP_INI no longer has recorded candidate hash; no bytes were overwritten'
SOCKET_CURRENT="$(stat -Lc '%d:%i:%u:%g:%a:%F' "$FASTCGI_SOCKET")"; SOCKET_BEFORE="$(json_value socket_before)"; SOCKET_AFTER="$(jq -er '.socket_after // empty' "$STATE")"
if [[ -n "$SOCKET_AFTER" ]]; then [[ "$SOCKET_CURRENT" == "$SOCKET_AFTER" ]] || critical 'Socket identity differs from the recorded after state'; else [[ "$SOCKET_CURRENT" == "$SOCKET_BEFORE" ]] || critical 'Socket identity differs from the recorded pre-reload state'; fi
collect_fpm_graph "${ROLLBACK_DIR}/fpm-current" || critical 'Cannot capture current FPM graph'; cmp -s "${TX_DIR}/fpm-base.graph.tsv" "${ROLLBACK_DIR}/fpm-current.graph.tsv" || critical 'FPM config/pool graph changed'
grep -Fqx "$POOL_CONFIG" "${ROLLBACK_DIR}/fpm-current.paths" || critical 'POOL_CONFIG is no longer included'; capture_php_graph "${ROLLBACK_DIR}/php-current" || critical 'Cannot capture current PHP graph'; cmp -s "${TX_DIR}/php-candidate.php.graph.tsv" "${ROLLBACK_DIR}/php-current.php.graph.tsv" || critical 'Current PHP graph differs from recorded candidate graph'
run_fpm -tt >/dev/null 2>&1 || critical 'Current candidate fails -tt; no bytes were overwritten'

CURRENT_COPY="${ROLLBACK_DIR}/php.ini.current"; CURRENT_SHA="${ROLLBACK_DIR}/php.ini.current.sha256"; cp --archive -- "$PHP_INI" "$CURRENT_COPY" || critical 'Could not preserve current bytes'; [[ "$(hash_file "$CURRENT_COPY")" == "$CANDIDATE_HASH" ]] || critical 'Current rescue hash mismatch'; printf '%s  %s\n' "$CANDIDATE_HASH" "$CURRENT_COPY" >"$CURRENT_SHA"; chmod 600 "$CURRENT_SHA"; sha256sum --check "$CURRENT_SHA" || critical 'Persistent current SHA failed'
install_current_after_base_failure() {
  local REASON="$1" STAGE="${ROLLBACK_DIR}/php.current.restore.stage"; cp --archive -- "$CURRENT_COPY" "$STAGE" || critical "$REASON; current bytes could not be staged"; chown "$BASE_UID:$BASE_GID" "$STAGE"; chmod "$BASE_MODE" "$STAGE"; [[ "$(hash_file "$STAGE")" == "$CANDIDATE_HASH" && "$(stat -Lc '%d' "$STAGE")" == "$(stat -Lc '%d' "$PHP_INI")" ]] || critical "$REASON; current stage mismatch"; mv -T -- "$STAGE" "$PHP_INI" || critical "$REASON; atomic current rename failed"; [[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" ]] || critical "$REASON; current hash is not on disk"; run_fpm -tt >/dev/null 2>&1 || critical "$REASON; base and repeated current tests failed; no reload occurred"; critical "$REASON; current hash/test and rescue paths are recorded; no reload occurred"
}

BASE_STAGE="${ROLLBACK_DIR}/php.ini.base.stage"; cp --archive -- "$BACKUP" "$BASE_STAGE" || critical 'Could not stage base backup'; chown "$BASE_UID:$BASE_GID" "$BASE_STAGE"; chmod "$BASE_MODE" "$BASE_STAGE"; [[ "$(hash_file "$BASE_STAGE")" == "$BASE_HASH" && "$(stat -Lc '%d' "$BASE_STAGE")" == "$(stat -Lc '%d' "$PHP_INI")" ]] || critical 'Base stage hash/filesystem mismatch'
verify_lock || critical 'Lock changed before atomic rollback'; [[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" ]] || critical 'PHP_INI changed before atomic rollback'
unexpected_after_mutation() { local RC=$?; trap - ERR; critical "unexpected command failure after rollback mutation (rc=${RC}, line=${BASH_LINENO[0]:-unknown}); state was not assumed"; }
trap unexpected_after_mutation ERR
mv -T -- "$BASE_STAGE" "$PHP_INI" || critical 'Atomic base rename failed'; [[ "$(hash_file "$PHP_INI")" == "$BASE_HASH" && "$(stat -Lc '%u:%g:%a' "$PHP_INI")" == "${BASE_UID}:${BASE_GID}:${BASE_MODE}" ]] || critical 'Base disk hash or attributes mismatch'
run_fpm -tt >/dev/null 2>&1 || install_current_after_base_failure 'Base FPM -tt failed'; capture_php_graph "${ROLLBACK_DIR}/php-base-tested" || install_current_after_base_failure 'Base FPM -i failed'; cmp -s "${TX_DIR}/php-base.php.graph.tsv" "${ROLLBACK_DIR}/php-base-tested.php.graph.tsv" || install_current_after_base_failure 'Base PHP graph differs from recorded graph'

verify_lock || critical 'Lock changed immediately before rollback reload'; [[ "$(hash_file "$PHP_INI")" == "$BASE_HASH" ]] || critical 'PHP_INI changed before reload'; capture_fpm; load_master_environment "${ROLLBACK_DIR}/fpm.environment.pre-reload.nul"; collect_fpm_graph "${ROLLBACK_DIR}/fpm-pre-reload" || critical 'Final FPM graph capture failed'; cmp -s "${TX_DIR}/fpm-base.graph.tsv" "${ROLLBACK_DIR}/fpm-pre-reload.graph.tsv" || critical 'FPM graph changed before reload'; capture_php_graph "${ROLLBACK_DIR}/php-pre-reload" || critical 'Final PHP graph capture failed'; cmp -s "${TX_DIR}/php-base.php.graph.tsv" "${ROLLBACK_DIR}/php-pre-reload.php.graph.tsv" || critical 'PHP graph changed before reload'; run_fpm -tt >/dev/null 2>&1 || install_current_after_base_failure 'Final base -tt failed'
verify_lock || critical 'Lock changed after the final base test; no reload occurred'; [[ "$(hash_file "$PHP_INI")" == "$BASE_HASH" ]] || critical 'PHP_INI changed after the final base test; no reload occurred'; capture_fpm; load_master_environment "${ROLLBACK_DIR}/fpm.environment.reload-gate.nul"; collect_fpm_graph "${ROLLBACK_DIR}/fpm-reload-gate" || critical 'Reload-gate FPM graph failed; no reload occurred'; cmp -s "${TX_DIR}/fpm-base.graph.tsv" "${ROLLBACK_DIR}/fpm-reload-gate.graph.tsv" || critical 'FPM graph changed after the final test; no reload occurred'; capture_php_graph "${ROLLBACK_DIR}/php-reload-gate" || critical 'Reload-gate PHP graph failed; no reload occurred'; cmp -s "${TX_DIR}/php-base.php.graph.tsv" "${ROLLBACK_DIR}/php-reload-gate.php.graph.tsv" || critical 'PHP graph changed after the final test; no reload occurred'
if ! systemctl reload "$PHP_FPM_UNIT"; then
  echo "Reload failed: disk hash was ${BASE_HASH}; FPM master/worker memory is unknown and may still use candidate settings." >&2
  systemctl status "$PHP_FPM_UNIT" --no-pager || true; journalctl --unit="$PHP_FPM_UNIT" --since='-10 minutes' --no-pager || true
  critical 'Do not restart blindly. Keep rescue copies and SSH; inspect logs, then retry only after exact validation or atomically recover CURRENT_COPY'
fi
systemctl is-active --quiet "$PHP_FPM_UNIT" || critical 'Rollback reload returned success but the exact unit is not active'; capture_fpm; load_master_environment "${ROLLBACK_DIR}/fpm.environment.after-reload.nul"
[[ "$(hash_file "$PHP_INI")" == "$BASE_HASH" ]] || critical 'PHP_INI changed after rollback reload'; collect_fpm_graph "${ROLLBACK_DIR}/fpm-after" || critical 'Post-rollback FPM graph failed'; cmp -s "${TX_DIR}/fpm-base.graph.tsv" "${ROLLBACK_DIR}/fpm-after.graph.tsv" || critical 'Post-rollback FPM graph differs'; capture_php_graph "${ROLLBACK_DIR}/php-after" || critical 'Post-rollback PHP graph failed'; cmp -s "${TX_DIR}/php-base.php.graph.tsv" "${ROLLBACK_DIR}/php-after.php.graph.tsv" || critical 'Post-rollback PHP graph differs'

PROBE_DIR=''
cleanup_probe() { local RC=0; if [[ -n "$PROBE_DIR" && -d "$PROBE_DIR" && ! -L "$PROBE_DIR" ]]; then [[ "$(stat -Lc '%u' "$PROBE_DIR")" == 0 ]] || RC=1; find "$PROBE_DIR" -maxdepth 1 -type f -delete || RC=1; rmdir -- "$PROBE_DIR" || RC=1; PROBE_DIR=''; fi; return "$RC"; }
trap 'if ! cleanup_probe; then echo "CRITICAL non-convergent: rollback probe cleanup failed" >&2; fi' EXIT
probe_fpm() {
  local DEST="$1" RAW="${1}.raw" PROBE PID WORKER_BIN WORKER_CGROUP; PROBE_DIR="$(mktemp -d --tmpdir=/run .teramont-fpm-rollback-probe.XXXXXXXX)"; chown "root:$POOL_GROUP" "$PROBE_DIR"; chmod 710 "$PROBE_DIR"; PROBE="${PROBE_DIR}/probe.php"
  cat >"$PROBE" <<'PHP'
<?php
header('Content-Type: application/json');
echo json_encode([
    'pid' => getmypid(),
    'sapi' => PHP_SAPI,
    'loaded_ini' => php_ini_loaded_file(),
    'scanned_ini' => php_ini_scanned_files(),
    'upload_max_filesize' => ini_get('upload_max_filesize'),
    'post_max_size' => ini_get('post_max_size'),
], JSON_THROW_ON_ERROR);
PHP
  chown "$POOL_USER:$POOL_GROUP" "$PROBE"; chmod 400 "$PROBE"; env -i SCRIPT_FILENAME="$PROBE" SCRIPT_NAME='/restricted-local-probe.php' REQUEST_METHOD='GET' SERVER_PROTOCOL='HTTP/1.1' GATEWAY_INTERFACE='CGI/1.1' REDIRECT_STATUS='200' "$CGI_FCGI_BIN" -bind -connect "$FASTCGI_SOCKET" >"$RAW" || { cleanup_probe || critical 'Probe and cleanup failed'; return 1; }; cleanup_probe || critical 'Probe cleanup failed'; sed '1,/^\r$/d' "$RAW" >"$DEST"; chmod 600 "$RAW" "$DEST"; jq empty "$DEST" || return 1
  PID="$(jq -er '.pid|select(type=="number" and .>1)|floor' "$DEST")" || return 1; WORKER_BIN="$(readlink -f -- "/proc/${PID}/exe")"; [[ "$WORKER_BIN" == "$FPM_BIN" ]] || return 1; WORKER_CGROUP="$(awk -F: '$1=="0"{print $3}' "/proc/${PID}/cgroup")"; [[ "$WORKER_CGROUP" == "$FPM_CGROUP" || "$WORKER_CGROUP" == "$FPM_CGROUP"/* ]] || return 1
}
AFTER_PROBE="${ROLLBACK_DIR}/probe.after-rollback.json"; probe_fpm "$AFTER_PROBE" || critical 'Rollback probe did not tie worker PID/exe/cgroup to selected unit'; jq -e --slurpfile before "$BEFORE_PROBE" 'del(.pid)==($before[0]|del(.pid))' "$AFTER_PROBE" >/dev/null || critical 'Rollback live values differ from protected before probe'; normalize_ini_list "$(jq -r '.scanned_ini' "$AFTER_PROBE")" "${AFTER_PROBE}.scanned" || critical 'Rollback probe scanned list is invalid'; cmp -s "${ROLLBACK_DIR}/php-after.scanned" "${AFTER_PROBE}.scanned" || critical 'Rollback FPM -i scanned files differ from live probe'
SOCKET_ROLLBACK="$(stat -Lc '%d:%i:%u:%g:%a:%F' "$FASTCGI_SOCKET")"; jq -nS --arg exec_hash "$EXEC_HASH" --arg argv_hash "$ARGV_HASH" --arg env_hash "$ENV_HASH_EXPECTED" --arg cwd "$FPM_CWD" --arg cgroup "$FPM_CGROUP" --arg socket "$SOCKET_ROLLBACK" --arg pool "$POOL_NAME" --arg fpm_graph "$(hash_file "${ROLLBACK_DIR}/fpm-after.graph.tsv")" --arg php_graph "$(hash_file "${ROLLBACK_DIR}/php-after.php.graph.tsv")" --arg probe_hash "$(hash_file "$AFTER_PROBE")" '{execstart_sha256:$exec_hash,argv_sha256:$argv_hash,environment_sha256:$env_hash,cwd:$cwd,cgroup:$cgroup,socket:$socket,pool:$pool,fpm_graph_sha256:$fpm_graph,php_graph_sha256:$php_graph,probe_sha256:$probe_hash}' >"${ROLLBACK_DIR}/rollback-snapshot.json"; chmod 600 "${ROLLBACK_DIR}/rollback-snapshot.json"
printf 'Rollback reload command succeeded; disk/config graphs and restricted worker probe match the protected base snapshot. Retain rescue paths until the identical application request is verified.\n'

Antes de adoptar estos bloques, ensaya en un clon no productivo fallos inyectados en copia de stage, rename, hash, test del backup, recuperación de bytes actuales y reload. Cada ensayo debe demostrar que no se recarga tras un test fallido, que los hashes y rutas de rescate sobreviven y que cualquier incertidumbre termina en CRITICAL non-convergent. Son pruebas recomendadas, no pruebas ejecutadas para este artículo.

Problemas frecuentes después del cambio

nginx -T muestra varias directivas. Traza la jerarquía httpserverlocation para la URL real. El valor efectivo depende del contexto que atiende la solicitud, no de la última línea que encuentres. Editaste un archivo, pero no aparece en nginx -T. No está incluido o editaste otra instalación/contenedor. Revierte el archivo innecesario y corrige solo la fuente efectiva. El 413 solo aparece a través del dominio público. Investiga CDN/WAF/proxy y compara una prueba de origen autorizada. No desactives el borde global ni publiques la IP para “confirmarlo”. WordPress conserva un límite menor. Comprueba el pool y SAPI activos, reinicia el diagnóstico desde sus INI adicionales y confirma que post_max_size sea mayor que upload_max_filesize. Plugins o filtros también pueden reducir el valor visible. El límite está alineado, pero fallan archivos grandes. Revisa espacio, inodos, permisos y rutas temporales de Nginx/PHP, además de timeouts y límites propios de la aplicación. No lo llames 413 si el código o log dice otra cosa.

Preguntas frecuentes sobre el error 413 en Nginx

¿Por qué cambiar PHP no corrige un 413 de Nginx?

Porque Nginx puede rechazar el cuerpo antes de enviarlo a PHP-FPM. Corrige primero la capa que produjo la respuesta y luego alinea las posteriores.

¿Puedo arreglarlo con .htaccess?

No para Nginx. Ese archivo pertenece al modelo de configuración de Apache; en una pila Nginx debes cambiar la configuración efectiva de Nginx y, si procede, PHP-FPM.

¿Conviene usar client_max_body_size 0?

No como solución general. Nginx documenta que 0 desactiva su comprobación. Mantén un máximo finito basado en el requisito y protege el endpoint.

¿Reload o restart?

Para esta directiva, valida y usa reload de la unidad exacta. Conserva conexiones atendidas por workers antiguos y evita un reinicio innecesario.

Documenta el límite y vigila la cadena

Registra por endpoint el tamaño requerido, el margen, el propietario y los límites de borde, Nginx, PHP y aplicación. Monitorea respuestas 413 y espacio temporal; repite la revisión después de cambiar CDN/WAF, proxy, contenedores, versión de PHP o pool. Si un WordPress con carga real sigue lento tras resolver la subida, separa ese diagnóstico con estas comprobaciones para acelerar WordPress.

La secuencia fiable es atribuir, inventariar, limitar, respaldar, validar, recargar, probar y conservar rollback. Si necesitas control de Nginx y PHP, compara los recursos y el acceso de un VPS con los requisitos medidos de tu aplicación.

Error 413 en Nginx: cómo localizar y corregir el límite de subida
GeneralNginxWordPressAdministración de servidoresInfraestructura
¿Te gustó este artículo?Compártelo:

Sobre el autor

Mizael Segovia

Mizael Segovia

CEO & Desarrollador Full Stack y DevOps en Teramont Host

Continúa explorando guías, noticias y análisis relacionados.

CTA Pattern

¿Necesitas ayuda con tu servidor?

Nuestro equipo está listo para ayudarte con cualquier duda o problema que tengas.

Contáctenos