Files
nextcloud/recover_bad_signature.sh
T

1877 lines
67 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
CUR_IFS=$IFS
script_name="$(basename $(realpath $0))"
script_dir="$(dirname $(realpath $0))"
conf_dir="${script_dir}/conf"
snippet_dir="${script_dir}/snippets"
report_dir="${script_dir}/reports"
# - Das Recovery-Verzeichnis wird bewusst NICHT unter script_dir
# - angelegt: liegen die Scripte z.B. unter /root/..., kann der
# - www-data-Prozess (der die entschluesselten Dateien tatsaechlich
# - schreibt) /root gar nicht erst durchqueren (Modus 700), selbst
# - wenn der Zielordner selbst an www-data gechownt wird - jedes
# - fopen() dort wuerde mit einem Fehler fehlschlagen. Stattdessen ein
# - eigener Pfad ausserhalb von /root und ausserhalb des Web-Docroots,
# - per conf-Datei ueberschreibbar (RECOVERY_BASE_DIR).
# -
DEFAULT_RECOVERY_BASE_DIR="/var/nc-recovery"
declare -a unsorted_website_arr
declare -a website_arr
declare -a unsorted_account_arr
declare -a account_arr
declare -a unsorted_selected_user_arr
declare -a selected_user_arr
LOCK_DIR="/tmp/${script_name%%.*}.LOCK"
log_file="${LOCK_DIR}/${script_name%%.*}.log"
run_date=$(date +%Y-%m-%d-%H%M)
_encryption_flag_changed=false
_orig_skip_check=""
# =============
# --- Some functions
# =============
usage() {
[[ -n "$1" ]] && error "$1"
[[ $terminal ]] && echo -e "
\033[1mUsage:\033[m
$(basename $0) -s <website>
\033[1mDescription\033[m
Attempts to recover files that were reported as 'Bad Signature' by
scan_bad_signature.sh, by temporarily ignoring the Server-Side
Encryption signature check (Nextcloud's own
'encryption_skip_signature_check' config switch) and decrypting the
affected files anyway.
This only works for files whose signature check fails due to a
computation mismatch, NOT for files whose encrypted content itself
is corrupted on disk - those will still produce garbage. Every
recovered file is therefore validated afterwards (file-type magic
bytes, PDF/zip integrity where possible) so you can tell the two
cases apart.
IMPORTANT:
- Original files are NEVER touched, modified or deleted. Every
recovered file is written to a SEPARATE directory outside of
Nextcloud's own storage (default: ${DEFAULT_RECOVERY_BASE_DIR}/<website>,
override with RECOVERY_BASE_DIR in the conf file) - the exact
path is shown before the confirmation prompt.
- The 'encryption_skip_signature_check' config switch is set
instance-wide for the duration of this script and restored to
its previous value afterwards (also on interrupt/error).
- Recovered files may contain unencrypted personal data outside
of Nextcloud's storage - handle and delete the recovery
directory accordingly once you are done reviewing it.
Unless '-V' is given on the command line, you will be asked
interactively whether to run a normal recovery or a revalidate-only
pass (see '-V' below). You will then be asked which existing scan
report (produced by scan_bad_signature.sh) to use, and then which
account(s) from that report to attempt recovery for:
- a blank separated list of account names
- or 'all' to attempt every account found in the report
\033[1mOptions\033[m
-s <website>
The site of the nextcloud instance.
-V
Revalidate-only mode: does NOT touch 'encryption_skip_signature_check'
and does NOT decrypt anything again. Instead it re-runs the
(possibly improved) validation checks against files that were
already recovered by a previous, normal run - useful after this
script gained new file-type checks, without repeating the
instance-wide signature-skip window. Passing it here skips the
interactive mode question mentioned above.
\033[1mExample:\033[m
Runs this script on system 'cloud-01.oopen.de'
$(basename $0) -s cloud-01.oopen.de
Re-validates already recovered files on 'cloud-01.oopen.de' with
the current checks, without decrypting anything again:
$(basename $0) -V -s cloud-01.oopen.de
"
clean_up 1
}
restore_encryption_flag() {
# - Setzt encryption_skip_signature_check auf den Ursprungswert
# - zurueck, egal ob das Skript normal durchlaeuft oder
# - unterbrochen wird. Kein-Op, falls das Flag nie geaendert wurde.
# -
if [[ "$_encryption_flag_changed" = true ]] ; then
echononl " Restoring encryption_skip_signature_check.."
if [[ -z "$_orig_skip_check" ]] ; then
su -c "$PHP_BIN $INSTALL_DIR/occ config:system:delete encryption_skip_signature_check" -s /bin/bash $HTTP_USER > /dev/null 2> "$log_file"
else
su -c "$PHP_BIN $INSTALL_DIR/occ config:system:set encryption_skip_signature_check --value=$_orig_skip_check --type=boolean" -s /bin/bash $HTTP_USER > /dev/null 2> "$log_file"
fi
if [[ $? -eq 0 ]] ; then
echo_ok
_encryption_flag_changed=false
else
echo_failed
error "Could not restore encryption_skip_signature_check automatically! Please check/fix manually on the server: occ config:system:get encryption_skip_signature_check (should be '${_orig_skip_check:-<not set>}'). $(cat "$log_file")"
fi
fi
}
clean_up() {
# Perform program exit housekeeping
# Clear EXIT trap first so that the exit below does not fire it again.
trap - EXIT
restore_encryption_flag
[[ -n "$recovery_php_file" ]] && rm -f "$recovery_php_file" 2> /dev/null
rm -rf "$LOCK_DIR"
blank_line
exit $1
}
check_optional_validation_tools() {
# Checks whether optional tools that improve file-validation quality
# are installed. In interactive mode the user is offered to install
# any that are missing via apt. In non-interactive mode the function
# is a quiet no-op (missing tools degrade quality but do not abort).
local _missing_tools=()
local _missing_pkgs=()
local _desc=()
command -v identify > /dev/null 2>&1 || {
_missing_tools+=("identify")
_missing_pkgs+=("imagemagick")
_desc+=("identify (imagemagick) — PNG / TIFF / BMP / GIF: vollst. Dekodierung statt nur Magic-Bytes")
}
command -v ffprobe > /dev/null 2>&1 || {
_missing_tools+=("ffprobe")
_missing_pkgs+=("ffmpeg")
_desc+=("ffprobe (ffmpeg) — MP4 / MOV / M4V: Container-Parsing statt nur ftyp-Box-Suche")
}
command -v mp3val > /dev/null 2>&1 || {
_missing_tools+=("mp3val")
_missing_pkgs+=("mp3val")
_desc+=("mp3val (mp3val) — MP3: Frame-Struktur-Check statt nur ID3-Signatur")
}
command -v flac > /dev/null 2>&1 || {
_missing_tools+=("flac")
_missing_pkgs+=("flac")
_desc+=("flac (flac) — FLAC: Decode-Test statt nur fLaC-Signatur")
}
command -v ogginfo > /dev/null 2>&1 || {
_missing_tools+=("ogginfo")
_missing_pkgs+=("vorbis-tools")
_desc+=("ogginfo (vorbis-tools) — OGG / OGA / OPUS: Container-Check statt nur OggS-Signatur")
}
[[ ${#_missing_tools[@]} -eq 0 ]] && return 0
if ! $terminal ; then
return 0
fi
local _pkg_list="${_missing_pkgs[*]}"
echo ""
echo -e " \033[33mOptionale Validierungs-Tools fehlen – Checks laufen mit reduzierter Genauigkeit:\033[m"
echo ""
local _d
for _d in "${_desc[@]}" ; do
echo " ${_d}"
done
echo ""
echo -n " Jetzt installieren? apt install ${_pkg_list} [j/N]: "
read -r _yn
echo ""
if [[ "$_yn" =~ ^[jJyY]$ ]] ; then
echononl " Installiere Pakete: ${_pkg_list}.."
# shellcheck disable=SC2086
if apt-get install -y ${_missing_pkgs[*]} > /dev/null 2>&1 ; then
echo_ok
else
echo_failed
echo ""
echo " Bitte manuell installieren:"
echo " apt install ${_pkg_list}"
fi
else
echo " Übersprungen — Validierung läuft mit reduzierter Genauigkeit."
fi
echo ""
}
is_number() {
return $(test ! -z "${1##*[!0-9]*}" > /dev/null 2>&1);
}
echononl(){
if $terminal ; then
echo X\\c > /tmp/shprompt$$
if [ `wc -c /tmp/shprompt$$ | awk '{print $1}'` -eq 1 ]; then
echo -e -n "$*\\c" 1>&2
else
echo -e -n "$*" 1>&2
fi
rm /tmp/shprompt$$
fi
}
echo_done() {
if $terminal ; then
echo -e "\033[75G[ \033[32mdone\033[m ]"
fi
}
echo_ok() {
if $terminal ; then
echo -e "\033[75G[ \033[32mok\033[m ]"
fi
}
echo_warning() {
if $terminal ; then
echo -e "\033[75G[ \033[33m\033[1mwarn\033[m ]"
fi
}
echo_failed(){
if $terminal ; then
echo -e "\033[75G[ \033[1;31mfailed\033[m ]"
fi
}
echo_skipped() {
if $terminal ; then
echo -e "\033[75G[ \033[37mskipped\033[m ]"
fi
}
fatal (){
echo ""
echo ""
if $terminal ; then
echo -e " [ \033[31m\033[1mFatal\033[m ]: \033[37m\033[1m$*\033[m"
echo ""
echo -e " \033[31m\033[1mScript will be interrupted..\033[m!"
else
echo " [ Fatal ]: $*"
echo ""
echo " Script was terminated...."
fi
clean_up 1
}
error(){
echo ""
if $terminal ; then
echo -e " [ \033[31m\033[1mError\033[m ]: $*"
else
echo " [ Error ]: $*"
fi
echo ""
}
warn (){
if $terminal ; then
echo ""
echo -e " [ \033[33m\033[1mWarning\033[m ]: $*"
echo ""
fi
}
info (){
if $terminal ; then
echo ""
echo -e " [ \033[32m\033[1mInfo\033[m ]: $*"
echo ""
fi
}
# - Remove leading/trailling whitespaces
# -
trim() {
local var="$*"
var="${var#"${var%%[![:space:]]*}"}" # remove leading whitespace characters
var="${var%"${var##*[![:space:]]}"}" # remove trailing whitespace characters
echo -n "$var"
}
## - Check if a given array (parameter 2) contains a given string (parameter 1)
## -
containsElement () {
local e
for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 0; done
return 1
}
## - Percentage (1 decimal) of parameter 1 (part) against parameter 2 (total)
## - Returns "0.0" if total is empty, zero or non-numeric.
## -
calc_percent() {
local _part="$1"
local _total="$2"
if [[ -z "$_total" ]] || ! [[ "$_total" =~ ^[0-9]+$ ]] || [[ "$_total" -eq 0 ]] ; then
echo "0.0"
else
awk -v b="$_part" -v t="$_total" 'BEGIN { printf "%.1f", (b/t)*100 }'
fi
}
## - Best-effort structural validation of a recovered (signature-skip
## - decrypted) file. Dispatches primarily on the original file
## - extension; falls back to content-based detection (libmagic /
## - 'file') for unknown or missing extensions, or as a last resort
## - a text/binary heuristic + size comparison. Prints "CLASS|detail" -
## - CLASS is one of VALID / INVALID / UNVERIFIED.
## -
validate_recovered_file() {
local _f="$1" _origpath="$2" _origsize="$3"
local _base="$(basename -- "$_origpath")"
local _ext=""
if [[ "$_base" == *.* ]] ; then
_ext="${_origpath##*.}"
_ext="$(echo "$_ext" | tr '[:upper:]' '[:lower:]')"
fi
local _size
_size=$(stat -c%s "$_f" 2> /dev/null)
[[ -z "$_size" ]] && _size=0
case "$_ext" in
pdf)
if _pdf_check "$_f" ; then
echo "VALID|$_pdf_check_detail"
else
echo "INVALID|$_pdf_check_detail"
fi
;;
jpg|jpeg)
if _jpeg_check "$_f" ; then
echo "VALID|$_jpeg_check_detail"
else
echo "INVALID|$_jpeg_check_detail"
fi
;;
png)
if command -v identify > /dev/null 2>&1 ; then
# Full decode: catches truncated data, corrupt IDAT chunks, bad CRCs
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded PNG ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode PNG"
fi
else
local _head
_head="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head" = "89504e470d0a1a0a" ]] ; then
echo "VALID|PNG signature ok (identify not installed — no deep check)"
else
echo "INVALID|PNG signature missing"
fi
fi
;;
gif)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded GIF ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode GIF"
fi
else
local _head6
_head6="$(head -c6 "$_f" 2> /dev/null)"
if [[ "$_head6" = "GIF87a" || "$_head6" = "GIF89a" ]] ; then
echo "VALID|GIF signature ok (identify not installed — no deep check)"
else
echo "INVALID|GIF signature missing"
fi
fi
;;
bmp)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded BMP ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode BMP"
fi
else
if [[ "$(head -c2 "$_f" 2> /dev/null)" = "BM" ]] ; then
echo "VALID|BMP signature ok (identify not installed — no deep check)"
else
echo "INVALID|BMP signature missing"
fi
fi
;;
tif|tiff)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded TIFF ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode TIFF"
fi
else
local _head4
_head4="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head4" = "49492a00" || "$_head4" = "4d4d002a" ]] ; then
echo "VALID|TIFF signature ok (identify not installed — no deep check)"
else
echo "INVALID|TIFF signature missing"
fi
fi
;;
pnm|pgm|ppm|pbm)
local _head2
_head2="$(head -c2 "$_f" 2> /dev/null)"
if [[ "$_head2" =~ ^P[1-6]$ ]] ; then
echo "VALID|PNM/PGM/PPM magic ok"
else
echo "INVALID|PNM/PGM/PPM magic (P1-P6) missing"
fi
;;
odt|ods|odp|odg|odf|ott|ots|otp|otg|docx|xlsx|pptx|docm|xlsm|pptm|dotm|xltm|potm|ppsm|ppsx|zip|epub)
# A password-protected MS Office file (Standard/Agile encryption,
# MS-OFFCRYPTO) is NOT a zip at all, regardless of its docx/xlsx/
# pptx extension: the whole package is wrapped in an OLE2/CFBF
# compound-file container (the same format legacy .doc/.xls/.ppt
# use), with the real zip payload encrypted inside an internal
# stream. 'unzip -t' correctly fails on that - not because
# anything is corrupt, but because it is legitimately not a zip
# file. Recognize this case before treating a failed zip check
# as corruption.
local _head8o
_head8o="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head8o" = "d0cf11e0a1b11ae1" ]] ; then
echo "VALID|OLE2/CFBF container signature ok - this is a password-protected Office file (encrypted package), not a plain zip, so the zip check does not apply; open it with the password to verify content"
elif command -v unzip > /dev/null 2>&1 ; then
# Run unzip inside setsid so it has no controlling terminal.
# Without setsid, a password-protected ZIP causes unzip to try
# opening /dev/tty to prompt for the password. When running in
# the background of a tmux session this generates SIGTTIN, which
# STOPS the process (ps state T). A stopped process cannot receive
# SIGTERM, so timeout hangs indefinitely waiting for a child that
# will never exit. With setsid the /dev/tty open fails immediately
# with ENXIO and unzip exits with a non-zero code instead.
# -k 5: send SIGKILL 5 s after SIGTERM in case the process is
# still alive (e.g. stopped or in uninterruptible sleep).
local _unzip_exit
timeout -k 5 120 setsid unzip -tq "$_f" < /dev/null > /dev/null 2>&1
_unzip_exit=$?
if [[ $_unzip_exit -eq 0 ]] ; then
echo "VALID|zip integrity ok"
elif [[ $_unzip_exit -eq 124 || $_unzip_exit -eq 137 ]] ; then
# 124 = killed by SIGTERM after timeout, 137 = killed by SIGKILL (128+9)
echo "UNVERIFIED|zip integrity check timed out after 120 s (file may be very large or corrupt; check manually with: unzip -t \"$_f\")"
else
local _badentry
_badentry="$(trim "$(timeout -k 5 120 setsid unzip -t "$_f" < /dev/null 2>&1 | grep -v '^Archive:' | grep -v '^[[:space:]]*$' | head -1)")"
if echo "$_badentry" | grep -qi "password\|encrypt\|need PK compat" ; then
echo "UNVERIFIED|zip is password-protected (cannot verify without password${_badentry:+; unzip says: ${_badentry}})"
else
echo "INVALID|zip integrity check failed${_badentry:+ (${_badentry})}"
fi
fi
else
echo "UNVERIFIED|unzip not installed"
fi
;;
mp4|mov|m4v)
if command -v ffprobe > /dev/null 2>&1 ; then
# Full container parse: detects truncated/corrupt streams
local _ffprobe_out _ffprobe_exit
_ffprobe_out="$(ffprobe -v error -show_streams "$_f" 2>&1)"
_ffprobe_exit=$?
if [[ $_ffprobe_exit -eq 0 ]] ; then
local _streams
_streams="$(echo "$_ffprobe_out" | grep -c '\[STREAM\]' || true)"
echo "VALID|ffprobe parsed container ok (${_streams} stream(s) found)"
else
local _ffprobe_err
_ffprobe_err="$(echo "$_ffprobe_out" | head -1)"
echo "INVALID|ffprobe failed to parse container${_ffprobe_err:+ (${_ffprobe_err})}"
fi
else
if head -c 64 "$_f" 2> /dev/null | grep -aq "ftyp" ; then
echo "VALID|mp4 ftyp box found (ffprobe not installed — no deep check)"
else
echo "INVALID|mp4 ftyp box not found"
fi
fi
;;
doc|xls|ppt|ole|msi)
local _head8
_head8="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head8" = "d0cf11e0a1b11ae1" ]] ; then
echo "VALID|OLE2 Compound File signature ok (legacy Office)"
else
echo "INVALID|OLE2 Compound File signature missing"
fi
;;
azw3|mobi|prc)
# PalmDOC/Mobipocket container: 8-byte type/creator id
# 'BOOKMOBI' at fixed offset 60 in the header.
local _sig
_sig="$(dd if="$_f" bs=1 skip=60 count=8 2> /dev/null)"
if [[ "$_sig" = "BOOKMOBI" ]] ; then
echo "VALID|BOOKMOBI (Kindle/Mobi) signature ok"
else
echo "INVALID|BOOKMOBI signature missing at offset 60"
fi
;;
ai)
# Modern Illustrator files are PDF-compatible by default;
# older ones are plain PostScript/EPS.
if [[ "$(head -c5 "$_f" 2> /dev/null)" = "%PDF-" ]] ; then
if _pdf_check "$_f" ; then
echo "VALID|AI (PDF-compatible) - $_pdf_check_detail"
else
echo "UNVERIFIED|AI (PDF-compatible) but $_pdf_check_detail"
fi
elif head -c 12 "$_f" 2> /dev/null | grep -qa '^%!PS-Adobe' ; then
echo "VALID|AI (legacy PostScript/EPS) header ok"
else
echo "INVALID|neither PDF- nor %!PS-Adobe header found"
fi
;;
htm|html)
if head -c 512 "$_f" 2> /dev/null | tr '[:upper:]' '[:lower:]' | grep -qE '<html|<!doctype html' ; then
echo "VALID|HTML tag found near start"
else
echo "INVALID|no <html>/<!DOCTYPE html> found near start"
fi
;;
css)
# Plain text + balanced braces is a solid signal: decryption
# garbage is essentially never valid, balanced-brace text.
if LC_ALL=C grep -qP '[\x00-\x08\x0E-\x1F\x7F]' "$_f" 2> /dev/null ; then
echo "INVALID|contains binary/control bytes, not plausible CSS text"
else
local _open _close
_open=$(grep -o '{' "$_f" 2> /dev/null | wc -l)
_close=$(grep -o '}' "$_f" 2> /dev/null | wc -l)
if [[ "$_open" -gt 0 && "$_open" -eq "$_close" ]] ; then
echo "VALID|plain text, ${_open} matching { } pairs"
else
echo "INVALID|plain text but { (${_open}) / } (${_close}) counts don't match"
fi
fi
;;
ttf|otf)
local _head4f
_head4f="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
# 00010000 = TrueType, 4F54544F = 'OTTO' (OpenType/CFF),
# 74727565 = 'true' (old Mac TrueType), 74746366 = 'ttcf' (collection)
if [[ "$_head4f" = "00010000" || "$_head4f" = "4f54544f" || "$_head4f" = "74727565" || "$_head4f" = "74746366" ]] ; then
echo "VALID|TrueType/OpenType font signature ok"
else
echo "INVALID|TrueType/OpenType font signature missing (head=$_head4f)"
fi
;;
woff)
if [[ "$(head -c4 "$_f" 2> /dev/null)" = "wOFF" ]] ; then
echo "VALID|WOFF font signature ok"
else
echo "INVALID|WOFF font signature missing"
fi
;;
woff2)
if [[ "$(head -c4 "$_f" 2> /dev/null)" = "wOF2" ]] ; then
echo "VALID|WOFF2 font signature ok"
else
echo "INVALID|WOFF2 font signature missing"
fi
;;
psd)
if [[ "$(head -c4 "$_f" 2> /dev/null)" = "8BPS" ]] ; then
echo "VALID|Photoshop (8BPS) signature ok"
else
echo "INVALID|Photoshop (8BPS) signature missing"
fi
;;
xcf)
if head -c 9 "$_f" 2> /dev/null | grep -qa '^gimp xcf' ; then
echo "VALID|GIMP (gimp xcf) signature ok"
else
echo "INVALID|GIMP (gimp xcf) signature missing"
fi
;;
mp3)
if command -v mp3val > /dev/null 2>&1 ; then
# mp3val always exits 0 but prints "No errors found" on success
local _mp3val_out
_mp3val_out="$(mp3val "$_f" 2>&1)"
if echo "$_mp3val_out" | grep -q 'No errors found' ; then
echo "VALID|mp3val: no errors found"
else
local _mp3_err
_mp3_err="$(echo "$_mp3val_out" | grep -iv '^mp3val\|^$' | head -2 | tr '\n' ' ' | sed 's/ $//')"
echo "INVALID|mp3val reported errors${_mp3_err:+ (${_mp3_err})}"
fi
else
local _head3 _head2hex
_head3="$(head -c3 "$_f" 2>/dev/null)"
_head2hex="$(head -c2 "$_f" 2>/dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head3" = "ID3" ]] || echo "$_head2hex" | grep -qE '^fff[bef2]' ; then
echo "VALID|MP3 ID3 tag / MPEG sync ok (mp3val not installed — no deep check)"
else
echo "INVALID|MP3: no ID3 header or MPEG frame sync found"
fi
fi
;;
flac)
if command -v flac > /dev/null 2>&1 ; then
# --test decodes without writing output; exit 0 = file is intact
local _flac_out _flac_exit
_flac_out="$(flac --silent --test "$_f" 2>&1)"
_flac_exit=$?
if [[ $_flac_exit -eq 0 ]] ; then
echo "VALID|flac --test: ok"
else
local _flac_err
_flac_err="$(echo "$_flac_out" | grep -v '^$' | tail -1)"
echo "INVALID|flac --test failed${_flac_err:+ (${_flac_err})}"
fi
else
local _head4hex
_head4hex="$(head -c4 "$_f" 2>/dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head4hex" = "664c6143" ]] ; then # "fLaC"
echo "VALID|FLAC 'fLaC' signature ok (flac not installed — no deep check)"
else
echo "INVALID|FLAC 'fLaC' signature missing"
fi
fi
;;
ogg|oga|ogv|opus)
if command -v ogginfo > /dev/null 2>&1 ; then
# ogginfo exits 0 if the Ogg container is intact
local _ogg_out _ogg_exit
_ogg_out="$(ogginfo "$_f" 2>&1)"
_ogg_exit=$?
if [[ $_ogg_exit -eq 0 ]] ; then
echo "VALID|ogginfo: container parsed ok"
else
local _ogg_err
_ogg_err="$(echo "$_ogg_out" | grep -iv '^Processing\|^$' | head -2 | tr '\n' ' ' | sed 's/ $//')"
echo "INVALID|ogginfo failed${_ogg_err:+ (${_ogg_err})}"
fi
else
local _head4ogg
_head4ogg="$(head -c4 "$_f" 2>/dev/null)"
if [[ "$_head4ogg" = "OggS" ]] ; then
echo "VALID|OGG 'OggS' signature ok (ogginfo not installed — no deep check)"
else
echo "INVALID|OGG 'OggS' signature missing"
fi
fi
;;
wav)
local _riff _wave
_riff="$(head -c4 "$_f" 2> /dev/null)"
_wave="$(dd if="$_f" bs=1 skip=8 count=4 2> /dev/null)"
if [[ "$_riff" = "RIFF" && "$_wave" = "WAVE" ]] ; then
echo "VALID|RIFF/WAVE signature ok"
else
echo "INVALID|RIFF/WAVE signature missing"
fi
;;
ico)
local _head4i
_head4i="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head4i" = "00000100" ]] ; then
echo "VALID|ICO signature ok"
else
echo "INVALID|ICO signature missing (head=$_head4i)"
fi
;;
rtf)
if head -c 6 "$_f" 2> /dev/null | grep -qa '^{\\rtf' ; then
echo "VALID|RTF signature ok"
else
echo "INVALID|RTF signature missing"
fi
;;
exe|dll|sys|scr|ocx|cpl)
# Windows PE: 'MZ' at offset 0, then at the offset stored (as a
# little-endian uint32) at 0x3C there must be a 'PE\0\0' signature.
local _mz _pe_off_hex _pe_off _pe_sig
_mz="$(head -c2 "$_f" 2> /dev/null)"
if [[ "$_mz" != "MZ" ]] ; then
echo "INVALID|PE 'MZ' header missing"
else
_pe_off_hex="$(dd if="$_f" bs=1 skip=60 count=4 2> /dev/null | od -An -tx1 | tr -d ' \n')"
# little-endian: reverse the byte order before interpreting as hex
_pe_off=$((16#${_pe_off_hex:6:2}${_pe_off_hex:4:2}${_pe_off_hex:2:2}${_pe_off_hex:0:2}))
_pe_sig="$(dd if="$_f" bs=1 skip="$_pe_off" count=4 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_pe_sig" = "50450000" ]] ; then
echo "VALID|PE ('MZ' + 'PE\\0\\0') signature ok"
else
echo "INVALID|'MZ' header ok but 'PE' signature missing at offset $_pe_off (got $_pe_sig)"
fi
fi
;;
cab)
if [[ "$(head -c4 "$_f" 2> /dev/null)" = "MSCF" ]] ; then
echo "VALID|Microsoft Cabinet (MSCF) signature ok"
else
echo "INVALID|Microsoft Cabinet (MSCF) signature missing"
fi
;;
js|mjs|json|xml|svg|txt|csv|tsv|ini|cfg|conf|log|md|yml|yaml|properties|sql|sh|bash|py|php|java|c|h|cpp|hpp)
# Plain-text formats: broken decryption output is essentially
# never clean, printable text over any non-trivial length, so
# "no binary/control bytes" is itself strong positive evidence -
# much stronger than for an unknown/binary extension, where the
# same check only feeds the generic fallback below.
if LC_ALL=C grep -qP '[\x00-\x08\x0E-\x1F\x7F]' "$_f" 2> /dev/null ; then
echo "INVALID|contains binary/control bytes, not plausible '.${_ext}' text"
else
echo "VALID|plain text, no binary/control bytes (no deeper '.${_ext}' syntax check performed)"
fi
;;
indd|indt|indl)
# Adobe InDesign document/template/library container. There is
# no open decoder to fully parse it, but every valid file (since
# CS2/CS4) starts with this fixed 16-byte GUID-like signature -
# a corrupted/garbage decryption result will essentially never
# reproduce it by chance.
local _head16
_head16="$(head -c16 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head16" = "0606edf5d81d46e5bd31efe7fe74b71d" ]] ; then
echo "VALID|InDesign container signature ok (content/layout itself not parsed - open in InDesign to fully confirm)"
else
echo "INVALID|InDesign container signature missing (head=$_head16)"
fi
;;
*)
_validate_by_content_or_heuristic "$_f" "$_ext" "$_size" "$_origsize"
;;
esac
}
## - PDF structural check shared by the 'pdf' case and the 'ai'
## - (PDF-compatible) case. Sets $_pdf_check_detail, returns 0/1.
## -
## - qpdf exit codes:
## - 0 no problems
## - 2 structural errors → file is genuinely corrupt/unreadable
## - 3 warnings only → minor spec non-conformances; every real
## - viewer opens the file without issues
## - Only exit code 2 is treated as INVALID here. Exit code 3 (warnings)
## - is reported as VALID with a note, preventing false positives for
## - real-world PDFs that have trivial non-conformances.
## -
_pdf_check() {
local _f="$1"
if [[ "$(head -c5 "$_f" 2> /dev/null)" != "%PDF-" ]] ; then
_pdf_check_detail="missing %PDF- header"
return 1
fi
if command -v qpdf > /dev/null 2>&1 ; then
local _qpdf_out _qpdf_exit
_qpdf_out="$(qpdf --check "$_f" 2>&1)"
_qpdf_exit=$?
if [[ $_qpdf_exit -eq 0 ]] ; then
_pdf_check_detail="qpdf --check ok"
return 0
elif [[ $_qpdf_exit -eq 3 ]] ; then
# Warnings only — no structural errors. File is readable by all
# standard PDF viewers; non-conformances are minor/cosmetic.
_pdf_check_detail="qpdf --check ok (warnings only — file is readable)"
return 0
else
# Exit code 2 (or unexpected): genuine structural errors.
local _first_err
_first_err="$(echo "$_qpdf_out" | grep -i 'error' | head -1 | sed 's/^[[:space:]]*//')"
_pdf_check_detail="qpdf --check failed (exit ${_qpdf_exit}${_first_err:+: ${_first_err}})"
return 1
fi
fi
_pdf_check_detail="PDF header ok (qpdf not installed, no deep check)"
return 0
}
## - JPEG structural check. A raw "does the file end in FFD9" byte check
## - produces false positives: many real-world JPEGs (EXIF/thumbnail
## - trailers, app-specific padding, camera-written footers) legally carry
## - a few extra bytes AFTER the actual End-Of-Image marker, so the file's
## - very last 2 bytes are often NOT ffd9 even though the image is 100%
## - intact and every viewer opens it fine. We therefore prefer an actual
## - JPEG decoder (which only cares whether the compressed image data is
## - intact, not what trails after it) and only fall back to the old
## - marker heuristic if no decoder tool is available.
## - Sets $_jpeg_check_detail, returns 0/1.
_jpeg_check() {
local _f="$1"
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
_jpeg_check_detail="ImageMagick 'identify' decoded the image ok"
return 0
else
_jpeg_check_detail="ImageMagick 'identify' failed to decode the image"
return 1
fi
fi
if command -v php > /dev/null 2>&1 ; then
local _phpout
_phpout="$(php -r '
error_reporting(0);
if (!function_exists("imagecreatefromjpeg")) { echo "NOGD"; exit(0); }
$im = @imagecreatefromjpeg($argv[1]);
if ($im === false) { echo "BAD"; exit(0); }
imagedestroy($im);
echo "OK";
' "$_f" 2> /dev/null)"
case "$_phpout" in
OK)
_jpeg_check_detail="PHP GD (imagecreatefromjpeg) decoded the image ok"
return 0
;;
BAD)
_jpeg_check_detail="PHP GD (imagecreatefromjpeg) failed to decode the image"
return 1
;;
esac
# NOGD or empty output: GD extension not available, fall through
fi
# last resort: the old, less reliable marker heuristic
local _head _tail
_head="$(head -c3 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
_tail="$(tail -c2 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head" = "ffd8ff" && "$_tail" = "ffd9" ]] ; then
_jpeg_check_detail="JPEG SOI/EOI markers ok (no decoder available for a deep check)"
return 0
elif [[ "$_head" = "ffd8ff" ]] ; then
_jpeg_check_detail="JPEG SOI marker ok but no decoder available for a deep check; raw EOI-at-EOF heuristic failed (head=$_head tail=$_tail) - this is NOT reliable, many valid JPEGs carry trailer bytes after EOI, verify manually or install ImageMagick/php-gd"
return 1
else
_jpeg_check_detail="JPEG SOI marker missing (head=$_head)"
return 1
fi
}
## - Fallback for extensions without a dedicated structural check (or
## - no extension at all): use 'file'/libmagic to see what the content
## - actually looks like. If it is confidently one of our known strong
## - types (this can happen for misnamed files, e.g. a '.pdf' saved
## - without the dot), re-run the matching dedicated check instead of
## - just guessing. Otherwise report the detected type plus a
## - text/binary plausibility check - never auto-classifies as INVALID
## - purely from an unrecognised type, since libmagic not knowing a
## - rare/proprietary format is not proof of corruption.
## -
_validate_by_content_or_heuristic() {
local _f="$1" _ext="$2" _size="$3" _origsize="$4"
local _magic=""
if command -v file > /dev/null 2>&1 ; then
_magic="$(file --mime-type -b "$_f" 2> /dev/null)"
fi
case "$_magic" in
application/pdf)
if _pdf_check "$_f" ; then
echo "VALID|content-detected PDF ('.${_ext:-<none>}' extension), $_pdf_check_detail"
else
echo "UNVERIFIED|content-detected PDF ('.${_ext:-<none>}' extension), but $_pdf_check_detail"
fi
return
;;
image/jpeg|image/png|image/gif|image/bmp|image/tiff|application/zip|video/mp4|video/quicktime)
echo "UNVERIFIED|content-detected as $_magic (extension was '.${_ext:-<none>}') - re-run with that extension to structurally validate, or check manually"
return
;;
esac
# - Kein starker Treffer ueber libmagic: zumindest grob zwischen
# - "sieht nach Text aus" und "enthaelt binaere/Steuerzeichen"
# - unterscheiden - reine Zufalls-/Entschluesselungs-Reste sind so
# - gut wie nie durchgehend druckbarer Text.
# -
local _looks_like_text="unknown"
if LC_ALL=C grep -qP '[\x00-\x08\x0E-\x1F\x7F]' "$_f" 2> /dev/null ; then
_looks_like_text="no (contains binary/control bytes)"
else
_looks_like_text="yes (no binary/control bytes found)"
fi
# Note: recovered (plaintext) is ALWAYS smaller than original
# (ciphertext on disk, incl. IV/signature/padding overhead per 8192-byte
# block) - roughly 74-86% of it for this instance. That's normal and
# expected, not a sign of corruption; we only flag it if the ratio is
# implausible (e.g. way too small, suggesting truncation).
local _size_note
if [[ -n "$_origsize" && "$_origsize" =~ ^[0-9]+$ && "$_origsize" -gt 0 && "$_size" -gt 0 ]] ; then
local _ratio_pct=$(( _size * 100 / _origsize ))
if [[ "$_ratio_pct" -ge 65 && "$_ratio_pct" -le 95 ]] ; then
_size_note="size ratio plausible (recovered ${_size} / original ${_origsize} = ${_ratio_pct}%, expected ~74-86% for this instance)"
else
_size_note="size ratio LOOKS OFF (recovered ${_size} / original ${_origsize} = ${_ratio_pct}%, expected ~74-86% - check manually)"
fi
else
_size_note="size comparison not possible (recovered ${_size} bytes, original ${_origsize:-?} bytes)"
fi
echo "UNVERIFIED|no dedicated check for '.${_ext:-<none>}', detected type: ${_magic:-file/libmagic not installed}, looks like text: ${_looks_like_text}, ${_size_note}"
}
blank_line() {
if $terminal ; then
echo ""
fi
}
# - Running in a terminal?
# -
if [[ -t 1 ]] ; then
terminal=true
else
terminal=false
fi
# - This script needs root privileges (chown/chmod of the recovery
# - directory, 'su' into the webserver user, 'occ config:system:set').
# - Fail fast with a clear message instead of a confusing permission
# - error somewhere in the middle of the run.
# -
if [[ "$(id -u)" -ne 0 ]] ; then
fatal "This script must be run as root (it needs to chown/chmod the recovery directory, 'su' into the webserver user and run 'occ config:system:set'). Please re-run as root, e.g. via sudo."
fi
# ----------
# - Jobhandling
# ----------
if pgrep -f "$(basename $0)" | grep -q -v $$ ; then
msg="A previos instance of script \"`basename $0`\" seems already be running."
echo ""
if $terminal ; then
echo -e "[ \033[31m\033[1mFatal\033[m ]: $msg"
echo ""
echo -e " \033[31m\033[1mScript was interupted\033[m!"
else
echo " [ Fatal ]: $msg"
echo ""
echo " Script was interupted!"
fi
echo
exit 1
else
if [[ -d "$LOCK_DIR" ]] ; then
rm -rf "$LOCK_DIR" 2> /dev/null
fi
fi
# - If job already runs, stop execution..
# -
if mkdir "$LOCK_DIR" 2> /dev/null ; then
# - Remove lockdir when the script finishes, or when it receives a signal
# -
trap 'clean_up 1' SIGHUP SIGINT SIGTERM
else
msg="A previos instance of script \"`basename $0`\" seems already be running."
echo ""
if $terminal ; then
echo -e "[ \033[31m\033[1mFatal\033[m ]: $msg"
echo ""
echo -e " \033[31m\033[1mScript was interupted\033[m!"
else
echo " [ Fatal ]: $msg"
echo ""
echo " Script was interupted!"
fi
echo
exit 1
fi
# -------------
# - Read in Commandline arguments
# -------------
REVALIDATE_ONLY=false
_revalidate_only_explicit=false
while getopts hVs: opt ; do
case $opt in
h) usage ;;
V) REVALIDATE_ONLY=true; _revalidate_only_explicit=true ;;
s) WEBSITE=$OPTARG ;;
\?) usage
esac
done
# =============
# --- Ask which mode to run in, unless '-V' was already given on the
# --- command line (that still works unchanged for scripted/non-
# --- interactive invocations).
# =============
if ! $_revalidate_only_explicit && $terminal ; then
blank_line
echo -e "\033[37m\033[1mWhich mode should this run use?\033[m"
echo ""
echo -e " \033[1m[1] Recovery\033[m - decrypt bad-signature files (temporarily skips the signature check), write them under ${DEFAULT_RECOVERY_BASE_DIR}/<website> and validate them"
echo " [2] Revalidate-only - re-run just the validation checks against files a previous recovery run already wrote to disk (same as '-V'); nothing is decrypted again and no config value is touched"
info "Just press Return to use the default: [1] Recovery."
echo -n " Select mode by number [1]: "
read _mode_choice
case "$(trim "$_mode_choice")" in
2) REVALIDATE_ONLY=true ;;
""|1) REVALIDATE_ONLY=false ;;
*) fatal "Invalid selection '$_mode_choice'." ;;
esac
fi
if [[ -z "$WEBSITE" ]] ; then
while IFS='' read -r -d '' _conf_file ; do
source $_conf_file
if [[ -n "$WEBSITE" ]] ; then
unsorted_website_arr+=("${WEBSITE}:$_conf_file")
fi
WEBSITE=""
done < <(find "${conf_dir}" -maxdepth 1 -type f -name "*.conf" -print0)
# - Sort array
# -
IFS=$'\n' website_arr=($(sort <<<"${unsorted_website_arr[*]}"))
# Which cloud instance (website) would you like to update
#
source ${snippet_dir}/get-cloud-instance-to-update.sh
else
while IFS='' read -r -d '' _conf_file ; do
if $(grep -E -q "WEBSITE=\"?${WEBSITE}\"?" ${_conf_file} 2> /dev/null) ; then
conf_file="${_conf_file}"
break
fi
done < <(find "${conf_dir}" -maxdepth 1 -type f -name "*.conf" -print0)
fi
# - Reset IFS
# -
IFS=$CUR_IFS
DEFAULT_SRC_BASE_DIR="/usr/local/src/nextcloud"
DEFAULT_HTTP_USER="www-data"
DEFAULT_HTTP_GROUP="www-data"
DEFAULT_PHP_ENGINE='FPM'
blank_line
echononl " Include Configuration file '$(basename "${conf_file}")'.."
if [[ ! -f $conf_file ]]; then
echo_skipped
fatal "Missing configuration file '$conf_file'."
else
source $conf_file
echo_ok
fi
DEFAULT_WEB_BASE_DIR="/var/www/${WEBSITE}"
[[ -n "$WEB_BASE_DIR" ]] || WEB_BASE_DIR=$DEFAULT_WEB_BASE_DIR
if [[ ! -d ${WEB_BASE_DIR} ]] ; then
fatal "Web base directory '$WEB_BASE_DIR' not found!"
fi
DATA_DIR="$(realpath ${WEB_BASE_DIR}/data)"
[[ -n "$PHP_ENGINE" ]] || PHP_ENGINE=$DEFAULT_PHP_ENGINE
INSTALL_DIR="$(realpath ${WEB_BASE_DIR}/nextcloud)"
CURRENT_VERSION="$(basename $INSTALL_DIR | cut -d"-" -f2)"
# - Recovery-Zielverzeichnis: bewusst ausserhalb von script_dir/WEB_BASE_DIR
# - (siehe Kommentar bei DEFAULT_RECOVERY_BASE_DIR weiter oben) - eine
# - per-Website Unterordner, den 'www-data' auch tatsaechlich erreichen
# - kann.
# -
[[ -n "$RECOVERY_BASE_DIR" ]] || RECOVERY_BASE_DIR=$DEFAULT_RECOVERY_BASE_DIR
recovery_dir="${RECOVERY_BASE_DIR}/${WEBSITE}"
# =============
# --- Some
# =============
# - Support systemd ?
# -
SYSTEMD_EXISTS=false
systemd=$(which systemd)
systemctl=$(which systemctl)
if [[ -n "$systemd" ]] || [[ -n "$systemctl" ]] ; then
SYSTEMD_EXISTS=true
fi
#clear
if $terminal ; then
echo ""
echo -e "\033[32m-----\033[m"
echo -e "Attempt to recover \033[1mBad Signature\033[m files on system \033[1m${WEB_BASE_DIR}\033[m"
echo -e "\033[1m
Original files are never touched. Recovered content is written to a
separate directory and must be reviewed manually.\033[m"
echo -e "\033[32m-----\033[m"
fi
# =============
# --- Some checks
# =============
DEFAULT_HTTP_USER="www-data"
DEFAULT_HTTP_GROUP="www-data"
NGINX_IS_ENABLED=false
APACHE2_IS_ENABLED=false
# Get Webservice environment as IS_HTTPD_RUNNING, HTTP_USER, HTTP_GROUP..
#
source ${snippet_dir}/get-webservice-environment.sh
# Check PHP Version
#
source ${snippet_dir}/get-php-major-version.sh
# Get full qualified PHP command
#
source ${snippet_dir}/get-path-of-php-command.sh
if [[ ! -x "$PHP_BIN" ]]; then
fatal "No PHP binary found!"
fi
# =============
# --- Check Server-Side Encryption status
# =============
blank_line
echononl " Check Server-Side Encryption status.."
_encryption_status_out="$(su -c "$PHP_BIN $INSTALL_DIR/occ encryption:status" -s /bin/bash $HTTP_USER 2> "$log_file")"
ENCRYPTION_ENABLED="unknown"
if [[ -s "$log_file" ]] ; then
echo_failed
error "$(cat "$log_file")"
elif echo "$_encryption_status_out" | grep -qiE 'enabled:[[:space:]]*true' ; then
echo_ok
ENCRYPTION_ENABLED="yes"
else
echo_warning
ENCRYPTION_ENABLED="no"
fi
if [[ "$ENCRYPTION_ENABLED" != "yes" && "$REVALIDATE_ONLY" = false ]] ; then
fatal "Server-Side Encryption is not enabled on ${WEBSITE} - a signature-skip recovery attempt makes no sense here."
fi
# =============
# --- Determine available bad-signature reports
# =============
mapfile -t _available_reports < <(ls -t "${report_dir}"/bad_signature_*.tsv 2> /dev/null)
if [[ ${#_available_reports[@]} -eq 0 ]] ; then
fatal "No scan report files (bad_signature_*.tsv) found in '${report_dir}'. Run scan_bad_signature.sh first."
fi
blank_line
if $terminal ; then
echo -e "\033[37m\033[1mAvailable bad-signature reports (newest first)\033[m"
echo ""
_i=1
for _f in "${_available_reports[@]}" ; do
printf " [%2d] %s\n" "$_i" "$(basename "$_f")"
(( _i++ ))
done
echo ""
fi
echo -n " Select report by number: "
read _report_choice
if ! [[ "$_report_choice" =~ ^[0-9]+$ ]] || [[ "$_report_choice" -lt 1 ]] || [[ "$_report_choice" -gt ${#_available_reports[@]} ]] ; then
fatal "Invalid selection '$_report_choice'."
fi
chosen_report="${_available_reports[$((_report_choice-1))]}"
# =============
# --- Determine accounts contained in the chosen report
# =============
unsorted_account_arr=($(awk -F'\t' 'NF==5 && $3 ~ /^[0-9]+$/ {print $1}' "$chosen_report" | sort -u))
IFS=$'\n' account_arr=($(sort <<<"${unsorted_account_arr[*]}"))
IFS=$CUR_IFS
if [[ ${#account_arr[@]} -eq 0 ]] ; then
fatal "No bad-signature entries found in '$(basename "$chosen_report")'."
fi
blank_line
if $terminal ; then
echo -e "\033[37m\033[1mAccounts with 'Bad Signature' entries in this report\033[m"
echo ""
for _a in "${account_arr[@]}" ; do
echo " - $_a"
done
echo ""
fi
echo -n " Account(s) to attempt recovery for (blank separated, or 'all'): "
read _input
while true ; do
IFS=' ' read -r -a unsorted_selected_user_arr <<< "$(trim "$_input")"
if [[ "${#unsorted_selected_user_arr[@]}" -eq 1 && "${unsorted_selected_user_arr[0],,}" = "all" ]] ; then
selected_user_arr=("${account_arr[@]}")
break
fi
_all_valid=true
for _u in "${unsorted_selected_user_arr[@]}" ; do
if ! containsElement "$_u" "${account_arr[@]}" ; then
error "Unknown account '$_u' (not found in the selected report)."
_all_valid=false
fi
done
if $_all_valid && [[ "${#unsorted_selected_user_arr[@]}" -gt 0 ]] ; then
IFS=$'\n' selected_user_arr=($(sort <<<"${unsorted_selected_user_arr[*]}"))
IFS=$CUR_IFS
break
fi
echo -n " Account(s) to attempt recovery for (blank separated, or 'all'): "
read _input
done
# =============
# --- Confirmation
# =============
mkdir -p "$report_dir" 2> /dev/null
recovery_report_file="${report_dir}/recovery_${WEBSITE}_${run_date}.tsv"
check_optional_validation_tools
if $terminal ; then
echo ""
if $REVALIDATE_ONLY ; then
echo -e "\033[1;32mStarting revalidate-only run for \033[1;37m${WEBSITE}\033[m"
else
echo -e "\033[1;32mStarting recovery attempt for \033[1;37m${WEBSITE}\033[m"
fi
echo ""
echo -e " Cloud instance to be scanned.........: $WEBSITE"
echo ""
echo -e " Report used...........................: $(basename "$chosen_report")"
echo -e " Account(s) to recover.................: \033[33m${selected_user_arr[*]}\033[m"
echo ""
if $REVALIDATE_ONLY ; then
echo -e " Existing recovered files under........: $recovery_dir"
echo -e " Revalidate-only report..................: reports/$(basename "${recovery_report_file}")"
else
echo -e " Recovered files will be written to....: $recovery_dir"
echo -e " Recovery report........................: reports/$(basename "${recovery_report_file}")"
fi
echo ""
if $REVALIDATE_ONLY ; then
info "Revalidate-only mode: nothing is decrypted again, no config value is changed. Only the current (possibly newer) validation checks are re-run against files already recovered by a previous, normal run."
else
warn "This will TEMPORARILY set 'encryption_skip_signature_check' to true instance-wide, so already-broken files can be decrypted despite failing the signature check. It is restored to its previous value automatically when this script ends (also on Ctrl-C or error). Files with genuinely corrupted ciphertext will still come out as garbage - every recovered file is validated afterwards, but please still spot-check important results yourself before relying on them."
fi
echo ""
echo -n " Type upper case 'YES' to continue executing with this parameters: "
read OK
if [[ "$OK" = "YES" ]] ; then
echo ""
echo ""
echo -e "\033[1;32mGoing to attempt recovery for each selected account on \033[1;37m$WEBSITE \033[m"
else
fatal "Abort by user request - Answer as not 'YES'"
fi
fi
if $terminal ; then
echo ""
echo -e " \033[1mTemporäre Dateien – werden laufend aktualisiert:\033[m"
echo ""
echo -e " Fortschritt / Debug-Meldungen (PHP-Ausgabe auf STDERR):"
echo -e " \033[1mtail -f ${log_file}\033[m"
echo ""
echo -e " Rohergebnis pro Account (PHP-Ausgabe auf STDOUT, Datei für Datei):"
echo -e " \033[1mtail -f ${LOCK_DIR}/result_<account>.tsv\033[m"
echo ""
fi
{
echo "=================================================================="
echo " Bad-Signature-Recovery-Versuch ${WEBSITE} ${run_date}"
echo "=================================================================="
echo ""
echo " Basis-Report: $(basename "$chosen_report")"
echo " Wiederhergestellte Dateien liegen unter: ${recovery_dir}"
echo ""
echo -e "path\trecovered_bytes\toriginal_size\tstatus\tvalidation\tdetail"
} > "$recovery_report_file"
if ! $REVALIDATE_ONLY ; then
# =============
# --- Temporarily enable encryption_skip_signature_check
# =============
blank_line
echononl " Reading current encryption_skip_signature_check value.."
_orig_skip_check="$(su -c "$PHP_BIN $INSTALL_DIR/occ config:system:get encryption_skip_signature_check" -s /bin/bash $HTTP_USER 2> /dev/null)"
_orig_skip_check="$(trim "$_orig_skip_check")"
echo_done
echononl " Enabling encryption_skip_signature_check (temporary)..."
su -c "$PHP_BIN $INSTALL_DIR/occ config:system:set encryption_skip_signature_check --value=true --type=boolean" -s /bin/bash $HTTP_USER > /dev/null 2> "$log_file"
if [[ $? -eq 0 ]] ; then
echo_ok
_encryption_flag_changed=true
# Trap signals AND normal/abnormal exit so that the encryption flag
# and temp files are always cleaned up — even on a syntax error or
# an unexpected crash (EXIT fires for any bash exit, including errors).
trap 'clean_up 1' SIGHUP SIGINT SIGTERM EXIT
else
echo_failed
fatal "Could not enable encryption_skip_signature_check: $(cat "$log_file")"
fi
fi
if ! $REVALIDATE_ONLY ; then
# =============
# --- Write the embedded PHP recovery script
# =============
recovery_php_file="${INSTALL_DIR}/.recover_bad_signature_$$.php"
cat > "$recovery_php_file" <<'PHP_RECOVER_EOF'
<?php
/**
* recover_bad_signature.php
*
* Attempts to decrypt a known list of 'Bad Signature' files while
* 'encryption_skip_signature_check' is enabled (set by the calling
* wrapper script), and writes the resulting bytes to a separate
* output directory. Never touches the original file.
*
* Usage: php recover_bad_signature.php <uid> <listFile> <outDir>
* listFile: TSV with "path\tsize\tmtime" per line (no header)
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
fwrite(STDERR, "DEBUG: Recovery-Skript gestartet, PID=" . getmypid() . "\n");
register_shutdown_function(function () {
$err = error_get_last();
if ($err !== null && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
fwrite(STDERR, "DEBUG: FATAL bei Shutdown: [{$err['type']}] {$err['message']} in {$err['file']}:{$err['line']}\n");
}
});
define('OC_CONSOLE', 1);
$oldWorkingDir = getcwd();
if ($oldWorkingDir === false) {
fwrite(STDERR, "Konnte aktuelles Arbeitsverzeichnis nicht ermitteln. Bitte mit absolutem Pfad aufrufen.\n");
exit(1);
}
chdir(__DIR__);
require_once __DIR__ . '/lib/base.php';
// Nextcloud's own bootstrap (lib/base.php) unconditionally calls
// set_time_limit(3600) as part of the require above - this OVERRIDES
// whatever -d max_execution_time was passed on the PHP command line,
// since it is a later, explicit runtime call. Undo that here, now that
// the require is done, so recovering a large account isn't killed
// after exactly one hour regardless of the CLI flag.
set_time_limit(0);
chdir($oldWorkingDir);
if (function_exists('posix_getuid') && posix_getuid() === 0) {
fwrite(STDERR, "Bitte NICHT als root ausfuehren, sondern als Webserver-User.\n");
exit(1);
}
try {
$uid = $argv[1] ?? null;
$listFile = $argv[2] ?? null;
$outDir = $argv[3] ?? null;
if ($uid === null || $listFile === null || $outDir === null) {
fwrite(STDERR, "Usage: php recover_bad_signature.php <uid> <listFile> <outDir>\n");
exit(1);
}
if (!is_file($listFile)) {
fwrite(STDERR, "Liste nicht gefunden: $listFile\n");
exit(1);
}
if (!is_dir($outDir)) {
@mkdir($outDir, 0750, true);
}
$rootFolder = \OC::$server->get(\OCP\Files\IRootFolder::class);
\OC_Util::setupFS($uid);
$lines = file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
$lines = [];
}
$total = 0;
$ok = 0;
$failed = 0;
echo "path\trecovered_bytes\toriginal_size\tstatus\n";
foreach ($lines as $line) {
$cols = explode("\t", $line);
$path = $cols[0] ?? '';
$origSize = $cols[1] ?? '';
if ($path === '') {
continue;
}
$total++;
try {
$node = $rootFolder->get($path);
} catch (\Throwable $e) {
$failed++;
$msg = str_replace(["\t", "\n", "\r"], ' ', $e->getMessage());
echo "$path\t0\t$origSize\tNOT_FOUND: $msg\n";
flush();
continue;
}
if (!($node instanceof \OCP\Files\File)) {
$failed++;
echo "$path\t0\t$origSize\tNOT_A_FILE\n";
flush();
continue;
}
// Zielpfad unterhalb outDir, gleiche relative Struktur wie
// innerhalb der Nextcloud-Dateien des Users (ohne /<uid>/files/)
$relative = preg_replace('#^/' . preg_quote($uid, '#') . '/files/#', '', $path);
if ($relative === null || $relative === '') {
$relative = ltrim($path, '/');
}
$targetPath = rtrim($outDir, '/') . '/' . $relative;
$targetDir = dirname($targetPath);
if (!is_dir($targetDir)) {
@mkdir($targetDir, 0750, true);
}
try {
$in = $node->fopen('r');
if ($in === false) {
throw new \RuntimeException('fopen lieferte false');
}
$out = fopen($targetPath, 'w');
if ($out === false) {
throw new \RuntimeException("Zieldatei konnte nicht geoeffnet werden: $targetPath");
}
$bytes = stream_copy_to_stream($in, $out);
fclose($in);
fclose($out);
if ($bytes === false) {
throw new \RuntimeException('stream_copy_to_stream schlug fehl');
}
$ok++;
echo "$path\t$bytes\t$origSize\tOK\n";
flush();
} catch (\Throwable $e) {
$failed++;
$msg = str_replace(["\t", "\n", "\r"], ' ', $e->getMessage());
echo "$path\t0\t$origSize\tERROR: " . get_class($e) . ": $msg\n";
flush();
@unlink($targetPath);
}
}
fwrite(STDERR, "\nFertig. Dateien verarbeitet: $total, erfolgreich gelesen: $ok, Fehler: $failed\n");
} catch (\Throwable $e) {
fwrite(STDERR, "\n!!! UNBEHANDELTE AUSNAHME !!!\n");
fwrite(STDERR, get_class($e) . ": " . $e->getMessage() . "\n");
fwrite(STDERR, "in " . $e->getFile() . ":" . $e->getLine() . "\n");
fwrite(STDERR, $e->getTraceAsString() . "\n");
exit(2);
}
PHP_RECOVER_EOF
chmod 644 "$recovery_php_file"
fi
# -----
# - Main part of the script
# -----
if $terminal ; then
echo ""
echo ""
echo -e "\033[37m\033[1mMain part of the script\033[m"
echo ""
fi
if $REVALIDATE_ONLY ; then
if [[ ! -d "$recovery_dir" ]] ; then
fatal "Revalidate-only mode: recovery directory '$recovery_dir' does not exist - run this script without -V first."
fi
else
mkdir -p "$recovery_dir" 2> /dev/null
# - 'mkdir -p' creates missing parent directories (e.g. RECOVERY_BASE_DIR
# - itself) subject to the current umask - if that happens to be
# - restrictive, the PARENT of recovery_dir can end up blocking
# - traversal for $HTTP_USER even though recovery_dir itself is fine.
# - Explicitly (re-)open both levels for traversal (RECOVERY_BASE_DIR is
# - only ever traversed, never written into directly).
# -
chmod 755 "$RECOVERY_BASE_DIR" 2> /dev/null
chmod 755 "$recovery_dir" 2> /dev/null
# - recovery_dir itself still belongs to root at this point though -
# - mode 755 only grants $HTTP_USER (as 'other', not the owner) read +
# - traverse, NOT write. Files are later written per-account into
# - freshly created (and per-account chown'ed) subdirectories, but
# - recovery_dir itself also needs to be owned by $HTTP_USER so the
# - write test below (and 'mkdir' calls done by the PHP process
# - running as $HTTP_USER) can actually create things directly in it.
# -
chown "$HTTP_USER":"$HTTP_GROUP" "$recovery_dir" 2> /dev/null
# - Frueher, klarer Fehler statt 780x der gleiche stille Flop: pruefen,
# - ob der Webserver-User ($HTTP_USER) hier tatsaechlich schreiben kann,
# - BEVOR der eigentliche Recovery-Lauf beginnt. Faengt u.a. den Fall
# - ab, dass RECOVERY_BASE_DIR auf ein fuer $HTTP_USER unerreichbares
# - Verzeichnis (z.B. unterhalb von /root) gesetzt wurde.
# -
_write_test_file="${recovery_dir}/.write_test_$$"
su -c "touch '$_write_test_file'" -s /bin/bash $HTTP_USER 2> "$log_file"
if [[ ! -f "$_write_test_file" ]] ; then
fatal "Cannot write to recovery directory '$recovery_dir' as user '$HTTP_USER' - check the permissions of every path component (in particular that none of them, e.g. a parent directory, is only accessible to root). $(cat "$log_file")"
fi
rm -f "$_write_test_file" 2> /dev/null
fi
declare -i total_processed=0
declare -i total_valid=0
declare -i total_invalid=0
declare -i total_unverified=0
declare -i total_read_errors=0
declare -i total_failed_users=0
for _user in "${selected_user_arr[@]}" ; do
_sep_len=${#_user}
[[ $_sep_len -lt 9 ]] && _sep_len=9
_sep_line="$(printf '%*s' "$_sep_len" '' | tr ' ' '-')"
{
echo ""
echo ""
echo "$_sep_line"
echo "$_user"
echo "$_sep_line"
} >> "$recovery_report_file"
list_file="${LOCK_DIR}/list_${_user}.tsv"
awk -F'\t' -v u="$_user" 'NF==5 && $1==u && $3 ~ /^[0-9]+$/ {print $2"\t"$3"\t"$4}' "$chosen_report" > "$list_file"
_user_total=$(wc -l < "$list_file")
if [[ $_user_total -eq 0 ]] ; then
warn "No bad-signature entries for account '${_user}' found in the selected report - skipping."
echo " [ keine Eintraege im gewaehlten Report ]" >> "$recovery_report_file"
continue
fi
user_out_dir="${recovery_dir}/${_user}"
user_result_tsv="${LOCK_DIR}/result_${_user}.tsv"
if $REVALIDATE_ONLY ; then
echononl " Revalidating account \033[1;37m${_user}\033[m (${_user_total} files).."
{
echo -e "path\trecovered_bytes\toriginal_size\tstatus"
while IFS=$'\t' read -r _p _osize _omtime ; do
[[ -z "$_p" ]] && continue
_rel="${_p#/${_user}/files/}"
_local="${user_out_dir}/${_rel}"
if [[ -f "$_local" ]] ; then
_b=$(stat -c%s "$_local" 2> /dev/null)
printf '%s\t%s\t%s\t%s\n' "$_p" "${_b:-0}" "$_osize" "OK"
else
printf '%s\t%s\t%s\t%s\n' "$_p" "0" "$_osize" "NOT_RECOVERED: no file found under ${user_out_dir} (run without -V first)"
fi
done < "$list_file"
} > "$user_result_tsv"
_rc=0
else
mkdir -p "$user_out_dir" 2> /dev/null
chown -R "$HTTP_USER":"$HTTP_GROUP" "$recovery_dir" 2> /dev/null
echononl " Recovering account \033[1;37m${_user}\033[m (${_user_total} files).."
# -d max_execution_time=0: decrypting/validating every file of a
# large account can run well over an hour; without this the PHP
# CLI process is killed by PHP's own execution-time limit
# (typically inherited from the webserver's php.ini) mid-run.
su -c "$PHP_BIN -d max_execution_time=0 $recovery_php_file $_user $list_file $user_out_dir" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file"
_rc=$?
fi
if [[ $_rc -ne 0 ]]; then
echo_failed
error "$(cat "$log_file")"
echo " [ FEHLER beim Recovery-Lauf - siehe Server-Log ]" >> "$recovery_report_file"
(( total_failed_users++ ))
continue
fi
echo_done
$terminal && echo ""
declare -i _user_valid=0
declare -i _user_invalid=0
declare -i _user_unverified=0
declare -i _user_read_errors=0
declare -a _user_invalid_lines=()
declare -a _user_unverified_lines=()
while IFS=$'\t' read -r _path _recbytes _origsize _status ; do
[[ "$_path" = "path" ]] && continue
[[ -z "$_path" ]] && continue
if [[ "$_status" == OK* ]] ; then
_rel="${_path#/${_user}/files/}"
_local="${user_out_dir}/${_rel}"
_val_result="$(validate_recovered_file "$_local" "$_path" "$_origsize")"
_val_class="${_val_result%%|*}"
_val_detail="${_val_result#*|}"
case "$_val_class" in
VALID) (( _user_valid++ )) ;;
INVALID) (( _user_invalid++ )); _user_invalid_lines+=("${_path}"$'\t'"${_val_detail}") ;;
*) (( _user_unverified++ )); _user_unverified_lines+=("${_path}"$'\t'"${_val_detail}") ;;
esac
else
_val_class="READ_ERROR"
_val_detail="$_status"
(( _user_read_errors++ ))
fi
# printf, not 'echo -e': $_val_detail (or $_status for a
# READ_ERROR row) can contain a raw PHP exception message (e.g.
# 'OCA\Encryption\Exceptions\...') - 'echo -e' would reinterpret
# those backslashes as escape sequences and silently mangle/eat
# parts of the text. printf's %s never reinterprets its argument,
# only the literal \t in the format string itself.
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$_path" "$_recbytes" "$_origsize" "$_status" "$_val_class" "$_val_detail" >> "$recovery_report_file"
done < "$user_result_tsv"
_user_pct_valid="$(calc_percent "$_user_valid" "$_user_total")"
if $terminal ; then
echo -e " \033[1;37mAccount ${_user}\033[m"
echo -e " Verarbeitete Dateien.....................: ${_user_total}"
if [[ $_user_valid -gt 0 ]] ; then
echo -e " Wiederhergestellt (valide)...............: \033[1;32m${_user_valid} (${_user_pct_valid} %)\033[m"
else
echo -e " Wiederhergestellt (valide)...............: ${_user_valid} (${_user_pct_valid} %)"
fi
if [[ $_user_invalid -gt 0 ]] ; then
echo -e " Datenmuell (ungueltig)...................: \033[1;31m${_user_invalid}\033[m"
fi
if [[ $_user_unverified -gt 0 ]] ; then
echo -e " Nicht pruefbar...........................: \033[33m${_user_unverified}\033[m"
fi
if [[ $_user_read_errors -gt 0 ]] ; then
echo -e " Weiterhin Lesefehler.....................: \033[1;31m${_user_read_errors}\033[m"
fi
echo ""
fi
# - 'Datenmuell' and 'Nicht pruefbar' files listed as their own,
# - visually set-off block(s), before the account statistics -
# - easier to scan/copy than digging them out of the full path list
# - above.
# -
{
if [[ $_user_invalid -gt 0 ]] ; then
echo ""
echo " ------------------------------------------------------------------"
echo " Datenmuell (ungueltig) - Account ${_user} (${_user_invalid})"
echo " ------------------------------------------------------------------"
for _line in "${_user_invalid_lines[@]}" ; do
printf ' %s\n' "$_line"
done
fi
if [[ $_user_unverified -gt 0 ]] ; then
echo ""
echo " ------------------------------------------------------------------"
echo " Nicht pruefbar - Account ${_user} (${_user_unverified})"
echo " ------------------------------------------------------------------"
for _line in "${_user_unverified_lines[@]}" ; do
printf ' %s\n' "$_line"
done
fi
} >> "$recovery_report_file"
{
echo ""
echo " Account ${_user}"
echo " Verarbeitete Dateien.....................: ${_user_total}"
echo " Wiederhergestellt (valide)...............: ${_user_valid} (${_user_pct_valid} %)"
[[ $_user_invalid -gt 0 ]] && echo " Datenmuell (ungueltig)...................: ${_user_invalid}"
[[ $_user_unverified -gt 0 ]] && echo " Nicht pruefbar...........................: ${_user_unverified}"
[[ $_user_read_errors -gt 0 ]] && echo " Weiterhin Lesefehler.....................: ${_user_read_errors}"
} >> "$recovery_report_file"
(( total_processed += _user_total ))
(( total_valid += _user_valid ))
(( total_invalid += _user_invalid ))
(( total_unverified += _user_unverified ))
(( total_read_errors += _user_read_errors ))
done
# =============
# --- Restore encryption_skip_signature_check as early as possible,
# --- before printing the overall summary.
# =============
restore_encryption_flag
total_pct_valid="$(calc_percent "$total_valid" "$total_processed")"
{
echo ""
echo ""
echo "=================================================================="
echo " Gesamtergebnis"
echo "=================================================================="
echo ""
echo "Gescannte Accounts..........................: ${#selected_user_arr[@]}"
[[ $total_failed_users -gt 0 ]] && echo "Accounts mit Recovery-Fehler................: ${total_failed_users}"
echo "Verarbeitete Dateien insgesamt..............: ${total_processed}"
echo "Wiederhergestellt insgesamt (valide)........: ${total_valid} (${total_pct_valid} %)"
[[ $total_invalid -gt 0 ]] && echo "Datenmuell insgesamt (ungueltig)............: ${total_invalid}"
[[ $total_unverified -gt 0 ]] && echo "Nicht pruefbar insgesamt....................: ${total_unverified}"
[[ $total_read_errors -gt 0 ]] && echo "Weiterhin Lesefehler insgesamt..............: ${total_read_errors}"
echo ""
echo "Wiederhergestellte Dateien liegen unter: ${recovery_dir}"
echo "Bitte Ergebnisse vor Weiterverwendung stichprobenartig pruefen,"
echo "und das Recovery-Verzeichnis danach sicher aufraeumen (enthaelt"
echo "unverschluesselte, ggf. sensible Nutzerdaten ausserhalb von Nextcloud)."
} >> "$recovery_report_file"
blank_line
if $terminal ; then
echo -e "\033[37m\033[1mErgebnis\033[m"
echo ""
echo -e " Gescannte Accounts..........................: ${#selected_user_arr[@]}"
[[ $total_failed_users -gt 0 ]] && echo -e " Accounts mit Recovery-Fehler................: \033[1;31m${total_failed_users}\033[m"
echo -e " Verarbeitete Dateien insgesamt..............: ${total_processed}"
if [[ $total_valid -gt 0 ]] ; then
echo -e " Wiederhergestellt insgesamt (valide)........: \033[1;32m${total_valid} (${total_pct_valid} %)\033[m"
else
echo -e " Wiederhergestellt insgesamt (valide)........: ${total_valid} (${total_pct_valid} %)"
fi
[[ $total_invalid -gt 0 ]] && echo -e " Datenmuell insgesamt (ungueltig)............: \033[1;31m${total_invalid}\033[m"
[[ $total_unverified -gt 0 ]] && echo -e " Nicht pruefbar insgesamt....................: \033[33m${total_unverified}\033[m"
[[ $total_read_errors -gt 0 ]] && echo -e " Weiterhin Lesefehler insgesamt..............: \033[1;31m${total_read_errors}\033[m"
echo ""
echo -e " Recovery-Verzeichnis.........................: $recovery_dir"
echo -e " Recovery-Report..............................: reports/$(basename "${recovery_report_file}")"
echo ""
warn "Wiederhergestellte Dateien vor Weiterverwendung stichprobenartig pruefen und das Recovery-Verzeichnis danach sicher aufraeumen - es enthaelt unverschluesselte, ggf. sensible Nutzerdaten ausserhalb von Nextcloud."
fi
clean_up 0