An Nginx 413 response means one layer in the request path refuses to process a body larger than it accepts. Do not raise an Nginx limit until you know whether the response came from an upstream CDN/WAF, Nginx itself, or a later layer such as PHP-FPM, WordPress, or your application. The safe fix is to change the smallest justified limit at the narrowest useful scope, test the configuration, and reload only after validation succeeds.
RFC 9110 §15.5.14 now calls the status 413 Content Too Large. The older wording Request Entity Too Large remains common in server pages and search results. Either phrase describes the outcome, not the component that generated it.
Before you start: scope and prerequisites
This runbook targets Ubuntu 24.04/26.04 or a comparable Debian release, with Nginx, sudo access, and a legitimate application requirement for a larger request. WordPress with PHP-FPM is the main example, while the layer-by-layer method also applies to a Node.js API. Replace the sample hostname, paths, PHP version, systemd unit, and endpoint with values proven on your host.
Take a recoverable snapshot or backup, keep a second SSH session open, and record the failure time with its timezone. Establish how you will restore the previous configuration before editing. The commands below are documented procedures that were statically checked for this article; they were not run on your VPS and are not evidence that any particular layer caused your incident.
1. Identify which layer returns the 413
Reproduce the same legitimate operation before making the test file any larger. Capture the status, headers, a non-sensitive portion of the response, URL, and exact time. Correlate that timestamp across every hop. A branded header or error page is useful evidence, but not proof: proxies can rewrite headers and custom pages can obscure the responding component.
| Signal | Likely layer | Next check |
|---|---|---|
| The edge returns 413 and Nginx has no matching request at that time. | CDN, WAF, or another proxy. | Review its events and policy; perform a controlled origin test with the same TLS hostname without publishing the origin IP or disabling protection globally. |
| The Nginx error log says the client body is too large. | Nginx. | Dump nginx -T and locate the block serving the hostname and path. |
Nginx forwards the request, while PHP has empty $_POST/$_FILES or WordPress displays a lower limit. | PHP-FPM or WordPress. | Identify the active SAPI, version, pool, and INI files, then compare both PHP size settings. |
| Nginx and PHP accept the body, but the application returns 413 or its own error. | Node.js, a framework, or another upstream service. | Inspect application logs and the body parser or endpoint configuration instead of blaming Nginx. |
| The configured limits look sufficient, but larger requests still fail. | Temporary storage, permissions, or capacity. | Check free space and inodes on the effective Nginx/PHP temporary paths and read their errors. |
Save the next block as a root-owned regular script in mode 0700 and run it with before. Sentinels abort until you provide the legitimate operation's method, URL, multipart field, non-sensitive file, and authentication. The upload and auth source must be private root-owned 0600 copies. The script snapshots them again inside a 0700 directory, makes curl read only those snapshots, and rehashes them after transmission. Headers, body, configuration, and logs can expose secrets, addresses, and request IDs; redact any copy before sharing.
Nginx identity comes from the typed ExecStart exposed by systemd's official D-Bus interface, never from the title in /proc/MainPID/cmdline: the Nginx source shows that title being overwritten. The property binary must match /proc/MainPID/exe, and the full vector retains -p, -c, -g, -e, and any other context for -t/-T. If D-Bus cannot yield one unambiguous command, stop and consult the administrator. The inventory supports a human review of server, location, logs, and fastcgi_pass; it does not resolve precedence.
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. Find the effective Nginx configuration
A file under /etc/nginx/sites-available has no effect unless the active configuration includes it. The official Nginx command-line reference says -t checks syntax and attempts to open referenced files; -T performs that test and also dumps the configuration. Find the correct server_name, follow its includes, determine which location wins for the upload path, and list every client_max_body_size. A more specific context may inherit or override the value you found elsewhere.
The Nginx directive reference documents a 1m default and permits the directive in http, server, and location. Requests above the configured value receive 413. A value of 0 disables the body-size check, so it is not a responsible blanket fix.
3. Choose a coherent limit at narrow scope
Start with the largest legitimate file or request body the endpoint must accept, then allow a small amount for multipart or other request overhead. There is no universally correct number. Raising the limit can increase abuse exposure, connection time, and pressure on CPU, memory, and temporary storage, so evaluate capacity, authentication, and rate controls independently.
If an entire virtual host needs one threshold, put the directive in that existing server. If only an import route needs it and the correct location already exists, use that narrower context. Do not paste a new location blindly; it can alter FastCGI or proxy routing. This excerpt uses 64m only as an adjustable example inside an existing server block:
nginx
server {
# Keep the existing listen, TLS, routing, and upstream settings unchanged.
server_name example.com;
# TERAMONT-NGINX-413-LIMIT
client_max_body_size 64m;
}
4. Back up, edit, test, and reload Nginx
Set CONFIG to the regular file with an exact header in nginx -T. First pause Ansible, the hosting panel, or any deployer that manages this graph. The transaction verifies the lock inode through its descriptor, records hashes of the systemd vector and every include, creates a root-owned backup and candidate on the same filesystem, and aborts on any drift. Its gate normalizes leading whitespace only and requires one marker/directive pair under the exact CONFIG header, displaying the observed server/location for human review. Immediately before reload it rechecks the lock, binary, full vector, installed file, and effective graph. That proves identity and inclusion, not inheritance or precedence.
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
A reload asks Nginx to read the configuration and transition workers gracefully; a restart is unnecessary for this directive. The official Nginx control documentation explains that the master checks and attempts the new configuration, continuing with the old one if applying it fails. The explicit preflight above remains a valuable operational gate.
5. Align PHP-FPM, WordPress, and the application
Passing Nginx does not guarantee that PHP accepts the request. upload_max_filesize applies to one uploaded file; post_max_size covers the complete POST body and must be larger. The PHP core INI documentation also notes that exceeding post_max_size leaves $_POST and $_FILES empty. For a legitimate file up to 60 MiB, these values illustrate some request overhead within the 64m Nginx example; derive your own pair:
; TERAMONT-PHP-413-LIMIT
upload_max_filesize = 60M
post_max_size = 64M
Choose the Control Your Stack Needs
Compare VPS plans and confirm access, resources, and scope before managing Nginx and PHP.


WordPress derives its upload maximum from the smaller value, as shown by wp_max_upload_size(), so this example can display 60 MB. WP_MEMORY_LIMIT does not fix a 413, and .htaccess does not configure Nginx. For broader stack context, see how to install WordPress on a VPS.
Do not treat CLI output from php --ini as proof of PHP-FPM. This flow starts at the effective fastcgi_pass and proves that its socket belongs to a pool included from the reviewed FPM_CONFIG. It reads structured ExecStart from the exact unit, compares its binary with /proc/MainPID/exe, and retains in order the context enumerated by the official PHP-FPM source, including -c, -d, -n, -y, and -p. Without -y, it accepts the default only when -tt uniquely identifies the reviewed file. Because PHP also consults environment variables while locating INI files, it runs -tt/-i from the master's cwd through env -i with the exact NUL-delimited master environment.
The administrative probe exists temporarily under /run, is readable only by the pool user, and is called directly with cgi-fcgi; it never exposes a public URL and is removed immediately. It also returns getmypid(). The flow requires that PID to belong to the unit's ControlGroup and its executable to match the canonical binary. It compares PHP_SAPI, php_ini_loaded_file(), scanned INIs, and effective limits with run_fpm -i, while freezing binary, argv, environment, cwd, cgroup, socket, pool, and config hashes before and after. If an identity cannot be proved—for example under an incompatible chroot—stop and request a restricted administrative check; never publish 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"
For Node.js or another API, inspect its framework, middleware, or endpoint body limit and application logs after Nginx. If Nginx or PHP runs in a container, do not edit the ephemeral container filesystem. Change and back up the persistent configuration or Compose source, validate inside the target container or candidate image, and recreate only the identified service. Keep the previous definition for rollback instead of turning one 413 into a stack-wide rebuild.
6. Verify the same operation end to end
The block runs after and accepts only the protected before.state.json for the same CASE_ID. It recalculates every artifact and manifest hash, validates the exact schema, and requires byte-logical equality of the identity object: method, URL, field, request, auth, script, unit, binary, test vector, and main config. Only then does it compare 413 with 2xx. You must still validate the functional result and correlate timestamps across the edge, Nginx, PHP, and application; a standalone 2xx does not prove an import completed.
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. Perform an exact rollback when needed
Use the exact TX_DIR printed by the transaction; never select whichever backup looks newest. The Nginx rollback rechecks the lock FD/inode, structured ExecStart, full vector, include graph, persistent hashes, and candidate bytes. It saves the current bytes, creates root-owned stages on the same filesystem, and uses atomic rename. If the base fails under the same context, it atomically reinstalls the current bytes, verifies their hash, and repeats the test. Every failure prints CRITICAL non-convergent with rescue paths and makes no convergence claim.
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"
The PHP rollback inherits the complete context: binary, argv, clean environment, cwd, cgroup, socket, pool, and FPM/PHP graphs. After reload it compares the local probe with the protected before snapshot except for PID, then persists a rollback snapshot. If reload fails, both flows distinguish known disk bytes from unknown master/worker memory, display status and journal, and tell you to preserve SSH and rescue copies; they neither recommend restart nor claim convergence.
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'
Before adopting these blocks, inject failures in a non-production clone at stage copy, rename, hash verification, base test, current-byte recovery, and reload. Each exercise should prove that no reload follows a failed test, rescue hashes and paths survive, and uncertainty ends in CRITICAL non-convergent. These are recommended tests, not tests executed for this article.
Troubleshooting after the change
nginx -T shows duplicate directives.
Trace the actual URL through http → server → location. The effective value follows the context serving the request, not the last line in search output.
Your edited file does not appear in nginx -T.
It is not included, or it belongs to another installation or container. Revert the unnecessary edit and change only the effective source.
413 occurs only through the public hostname.
Investigate the CDN/WAF/proxy and compare an authorized origin test. Do not expose the origin address or disable edge controls globally to prove the point.
WordPress still reports a smaller maximum.
Verify the active pool and SAPI, inspect its additional INI files, and ensure post_max_size exceeds upload_max_filesize. A plugin or filter can reduce the displayed value too.
Large files fail even though the limits align.
Check free space, inodes, permissions, and temporary paths for Nginx/PHP, followed by timeouts and application limits. Do not call the result 413 when the status and logs identify another failure.
Nginx 413 FAQ
Why does changing PHP not fix an Nginx 413?
Nginx can reject the body before forwarding anything to PHP-FPM. Fix the component that produced the response, then align every later layer.
Can .htaccess fix this on Nginx?
No. .htaccess belongs to Apache's configuration model. An Nginx stack requires the effective Nginx configuration and, where relevant, the active PHP-FPM INI.
Should I set client_max_body_size 0?
Not as a general fix. Nginx documents that zero disables its size check. Keep a finite requirement-based maximum and protect the endpoint.
Should I reload or restart?
After a successful test, reload the exact unit for this directive. It transitions workers gracefully and avoids an unnecessary restart.
Document the limit and monitor the whole path
For every large-body endpoint, record the requirement, margin, owner, and limits at the edge, Nginx, PHP, and application. Monitor 413 responses and temporary storage, then repeat the review after a CDN/WAF, proxy, container, PHP version, or pool change. If a production WordPress site remains slow after uploads work, treat performance separately with these checks for a slow WordPress site.
The reliable order is attribute, inventory, limit, back up, validate, reload, verify, and retain rollback. When your workload requires control over Nginx and PHP, compare the access and resources of a VPS against measured application needs.








