diff --git a/diagnose_share_key.sh b/diagnose_share_key.sh new file mode 100755 index 0000000..a309d94 --- /dev/null +++ b/diagnose_share_key.sh @@ -0,0 +1,956 @@ +#!/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" + +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) + + +# ============= +# --- Some functions +# ============= + +usage() { + + [[ -n "$1" ]] && error "$1" + + [[ $terminal ]] && echo -e " +\033[1mUsage:\033[m + + $(basename $0) -s + +\033[1mDescription\033[m + + READ-ONLY diagnostic for the 'Cannot decrypt this file, probably + this is a shared file. Please ask the file owner to reshare the + file with you.' and 'OCA\\Encryption\\Exceptions\\MultiKeyDecryptException' + errors that recover_bad_signature.sh reports as READ_ERROR (and + restore_bad_signature.sh can separately hit as WRITE_ERROR, even for + files that read back fine during recovery) and neither script can + fix - these are NOT signature problems, they are Server-Side + Encryption KEY-MATERIAL problems (a per-user 'share key' for a file + is either missing on disk or fails to decrypt with that user's + private key). This script accepts either a recovery_*.tsv or a + restore_*.tsv report as input. + + This script changes NOTHING. It never decrypts a file, never + touches 'encryption_skip_signature_check', and never writes + anywhere except its own report file. It only: + + 1. Reads a chosen recovery_*.tsv report (produced by + recover_bad_signature.sh) and picks out every row whose + validation is READ_ERROR and whose detail text matches one of + the two known key-material error signatures above. + 2. For each such file, resolves - via Nextcloud's normal, + read-only Files API (\$node->getOwner(), \$folder->getById()) + - who currently owns the file, i.e. whether the affected + account is the owner or only a share recipient. + 3. Checks, directly and only via plain filesystem existence + tests (no content is read), whether the expected + Server-Side-Encryption key files are present on disk: + - the affected user's OWN private/public keypair + (files_encryption/OC_DEFAULT_MODULE/.{private,public}Key) + - the file's 'shareKey' entry for that user, under the + file's OWNER's files_encryption tree + 4. Classifies every diagnosed file into one of: + NO_OWN_KEYPAIR - the affected user has no encryption + keypair on disk at all. This explains + EVERY file failing for that user, not + just this one - the account's own key + material is broken/missing, independent + of any single file or share. + OWNER_UNRESOLVED - the file's current owner could not be + determined via the Files API (e.g. the + file/share no longer exists as such). + NO_SHAREKEY - the owner could be resolved, and that + user HAS their own keypair, but no + shareKey file exists for them on this + specific file. + KEY_MATERIAL_PRESENT - both the user's keypair and this + file's shareKey exist on disk. The + failure is then a genuine + decrypt/mismatch problem for this + specific key (as with the + MultiKeyDecryptException case), not a + missing-key problem. + + Read the report's per-account summary first: if an account shows + NO_OWN_KEYPAIR for (almost) all its files, that account's own + encryption keypair is the thing to fix/restore, not the individual + files. + +\033[1mOptions\033[m + + -s + The site of the nextcloud instance. + +\033[1mExample:\033[m + + $(basename $0) -s cloud-02.oopen.de +" + + clean_up 1 + +} + + +clean_up() { + + # Perform program exit housekeeping + [[ -n "$diag_php_file" ]] && rm -f "$diag_php_file" 2> /dev/null + rm -rf "$LOCK_DIR" + blank_line + exit $1 + +} + +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 +} + +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 (reads other users' key files +# - under the Nextcloud data directory, 'su' into the webserver user +# - to resolve file ownership through the normal Files API). +# - +if [[ "$(id -u)" -ne 0 ]] ; then + fatal "This script must be run as root (it needs to read the Nextcloud data directory and 'su' into the webserver user). 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 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 +# ------------- +while getopts hs: opt ; do + case $opt in + h) usage ;; + s) WEBSITE=$OPTARG ;; + \?) usage + esac +done + + +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)" + + +# ============= +# --- Some +# ============= + +if $terminal ; then + echo "" + echo -e "\033[32m-----\033[m" + echo -e "Diagnose Server-Side-Encryption \033[1mkey-material\033[m problems on system \033[1m${WEB_BASE_DIR}\033[m" + echo -e "\033[1m + READ-ONLY: this script never decrypts anything, never writes to + Nextcloud storage and never touches 'encryption_skip_signature_check'. + It only inspects, on disk and via the normal Files API, whether the + expected encryption key files are present for accounts/files that + recover_bad_signature.sh could not read (READ_ERROR, not 'Bad + Signature').\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 + +systemd=$(which systemd) +systemctl=$(which systemctl) + +# 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 + + +# ============= +# --- Determine available reports to diagnose from - either a +# --- recover_bad_signature.sh report (recovery_*.tsv, READ_ERROR rows) +# --- or a restore_bad_signature.sh report (restore_*.tsv, WRITE_ERROR +# --- rows) - both can carry the same key-material error signatures, +# --- just at a different step (read vs. write). +# ============= + +mapfile -t _available_reports < <(ls -t "${report_dir}"/recovery_*.tsv "${report_dir}"/restore_*.tsv 2> /dev/null) + +if [[ ${#_available_reports[@]} -eq 0 ]] ; then + fatal "No recovery_*.tsv or restore_*.tsv report files found in '${report_dir}'. Run recover_bad_signature.sh (and/or restore_bad_signature.sh) first." +fi + +blank_line +if $terminal ; then + echo -e "\033[37m\033[1mAvailable reports (newest first, recovery_*.tsv and restore_*.tsv)\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))]}" + + +# ============= +# --- Extract "userpathdetail" for every row whose detail +# --- text matches one of the two known key-material error signatures +# --- - a recovery report's READ_ERROR rows (6 tab-separated fields, +# --- validation in $5) as well as a restore report's WRITE_ERROR rows +# --- (4 tab-separated fields, status in $3) both match: the same +# --- underlying key-material problem can surface at either step. Same +# --- section-header state machine as restore_bad_signature.sh uses +# --- for VALID rows. +# ============= + +key_error_entries_file="${LOCK_DIR}/key_error_entries.tsv" +awk -F'\t' ' + /^-+$/ { + if (state == 0) { state = 1 } + else if (state == 2) { state = 0 } + next + } + { + if (state == 1) { user = $0; state = 2; next } + if (NF == 6 && $1 != "path" && $5 == "READ_ERROR") { + if (index($6, "probably this is a shared file") > 0 || index($6, "MultiKeyDecryptException") > 0) { + print user "\t" $1 "\t" $6 + } + } else if (NF == 4 && $1 != "path" && $3 == "WRITE_ERROR") { + if (index($4, "probably this is a shared file") > 0 || index($4, "MultiKeyDecryptException") > 0) { + print user "\t" $1 "\t" $4 + } + } + } +' "$chosen_report" > "$key_error_entries_file" + +unsorted_account_arr=($(cut -f1 "$key_error_entries_file" | sort -u)) +IFS=$'\n' account_arr=($(sort <<<"${unsorted_account_arr[*]}")) +IFS=$CUR_IFS + +if [[ ${#account_arr[@]} -eq 0 ]] ; then + fatal "No key-material READ_ERROR/WRITE_ERROR entries ('...probably this is a shared file...' or 'MultiKeyDecryptException') found in '$(basename "$chosen_report")' - nothing to diagnose." +fi + +blank_line +if $terminal ; then + echo -e "\033[37m\033[1mAccounts with key-material READ_ERROR entries in this report\033[m" + echo "" + for _a in "${account_arr[@]}" ; do + _a_count=$(awk -F'\t' -v u="$_a" '$1==u' "$key_error_entries_file" | wc -l) + echo " - $_a (${_a_count})" + done + echo "" +fi + +echo -n " Account(s) to diagnose (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 diagnose (blank separated, or 'all'): " + read _input + +done + + +mkdir -p "$report_dir" 2> /dev/null +diag_report_file="${report_dir}/diagnose_share_key_${WEBSITE}_${run_date}.tsv" + +if $terminal ; then + echo "" + echo -e "\033[1;32mStarting key-material diagnosis for \033[1;37m${WEBSITE}\033[m" + echo "" + echo -e " Cloud instance..........................: $WEBSITE" + echo -e " Recovery report used....................: $(basename "$chosen_report")" + echo -e " Account(s) to diagnose..................: \033[33m${selected_user_arr[*]}\033[m" + echo -e " Diagnose report..........................: reports/$(basename "${diag_report_file}")" + echo "" + info "Read-only - nothing is decrypted, written or changed anywhere." +fi + + +{ + echo "==================================================================" + echo " Share-Key-Diagnose ${WEBSITE} ${run_date}" + echo "==================================================================" + echo "" + echo " Basis-Report (Recovery): $(basename "$chosen_report")" + echo " Diagnostiziert werden alle READ_ERROR-Eintraege mit den Fehlerbildern" + echo " 'probably this is a shared file' und 'MultiKeyDecryptException'." + echo " READ-ONLY: nichts wird entschluesselt, geschrieben oder veraendert." + echo "" + printf 'path\towner\thas_own_keypair\thas_filekey\thas_sharekey\tclassification\toriginal_error\n' +} > "$diag_report_file" + + +# ============= +# --- Write the embedded PHP diagnosis helper (read-only: resolves the +# --- current owner of each file via the normal Files API) +# ============= + +diag_php_file="${INSTALL_DIR}/.diagnose_share_key_$$.php" + +cat > "$diag_php_file" <<'PHP_DIAG_EOF' + + * listFile: one path per line + */ + +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +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'; +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; + + if ($uid === null || $listFile === null) { + fwrite(STDERR, "Usage: php diagnose_share_key.php \n"); + exit(1); + } + if (!is_file($listFile)) { + fwrite(STDERR, "Liste nicht gefunden: $listFile\n"); + exit(1); + } + + $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 = []; + } + + echo "path\towner\townerpath\tstatus\tdetail\n"; + + foreach ($lines as $path) { + if ($path === '') { + continue; + } + + try { + $node = $rootFolder->get($path); + } catch (\Throwable $e) { + $msg = str_replace(["\t", "\n", "\r"], ' ', $e->getMessage()); + echo "$path\t?\t?\tUNRESOLVED\t" . get_class($e) . ": $msg\n"; + flush(); + continue; + } + + $ownerUid = null; + try { + $owner = $node->getOwner(); + $ownerUid = $owner ? $owner->getUID() : null; + } catch (\Throwable $e) { + $ownerUid = null; + } + + if ($ownerUid === null) { + echo "$path\t?\t?\tOWNER_UNRESOLVED\tgetOwner() returned no owner\n"; + flush(); + continue; + } + + $ownerPath = $path; + if ($ownerUid !== $uid) { + try { + $ownerNodes = $rootFolder->getUserFolder($ownerUid)->getById($node->getId()); + if (!empty($ownerNodes)) { + $ownerPath = $ownerNodes[0]->getPath(); + } else { + echo "$path\t$ownerUid\t?\tOWNER_UNRESOLVED\tfile not found in owner's ($ownerUid) own tree via getById()\n"; + flush(); + continue; + } + } catch (\Throwable $e) { + $msg = str_replace(["\t", "\n", "\r"], ' ', $e->getMessage()); + echo "$path\t$ownerUid\t?\tOWNER_UNRESOLVED\t" . get_class($e) . ": $msg\n"; + flush(); + continue; + } + } + + echo "$path\t$ownerUid\t$ownerPath\tOK\t\n"; + flush(); + } +} 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_DIAG_EOF + +chmod 644 "$diag_php_file" + + +# ----- +# - Main part of the script +# ----- + +if $terminal ; then + echo "" + echo "" + echo -e "\033[37m\033[1mMain part of the script\033[m" + echo "" +fi + +declare -i total_selected=0 +declare -i total_no_own_keypair=0 +declare -i total_owner_unresolved=0 +declare -i total_no_sharekey=0 +declare -i total_no_filekey=0 +declare -i total_key_material_present=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" + } >> "$diag_report_file" + + # - This account's own keypair - a single, cheap, bash-only check + # - that alone explains an entire account failing on EVERY file, if + # - it is missing. + # - + _user_privkey="${DATA_DIR}/${_user}/files_encryption/OC_DEFAULT_MODULE/${_user}.privateKey" + _user_pubkey="${DATA_DIR}/${_user}/files_encryption/OC_DEFAULT_MODULE/${_user}.publicKey" + if [[ -f "$_user_privkey" && -f "$_user_pubkey" ]] ; then + _user_has_own_keypair=true + else + _user_has_own_keypair=false + fi + + path_list_file="${LOCK_DIR}/paths_${_user}.txt" + awk -F'\t' -v u="$_user" '$1==u { print $2 }' "$key_error_entries_file" > "$path_list_file" + + declare -i _user_selected=0 + _user_selected=$(wc -l < "$path_list_file") + + owner_result_tsv="${LOCK_DIR}/owner_${_user}.tsv" + + echononl " Resolving ownership for account \033[1;37m${_user}\033[m (${_user_selected} files).." + su -c "$PHP_BIN $diag_php_file $_user $path_list_file" -s /bin/bash $HTTP_USER > "$owner_result_tsv" 2> "$log_file" + _rc=$? + + if [[ $_rc -ne 0 ]]; then + echo_failed + error "$(cat "$log_file")" + echo " [ FEHLER bei der Eigentuemer-Aufloesung - siehe Server-Log ]" >> "$diag_report_file" + (( total_failed_users++ )) + continue + fi + + echo_done + + declare -i _user_no_own_keypair=0 + declare -i _user_owner_unresolved=0 + declare -i _user_no_sharekey=0 + declare -i _user_no_filekey=0 + declare -i _user_key_material_present=0 + declare -a _user_lines=() + + while IFS=$'\t' read -r _path _owner _ownerpath _status _odetail ; do + + [[ "$_path" = "path" ]] && continue + [[ -z "$_path" ]] && continue + + _orig_error="$(awk -F'\t' -v u="$_user" -v p="$_path" '$1==u && $2==p { print $3; exit }' "$key_error_entries_file")" + + if ! $_user_has_own_keypair ; then + (( _user_no_own_keypair++ )) + _class="NO_OWN_KEYPAIR" + _detail="account '${_user}' has no encryption keypair on disk at all (missing: ${_user_privkey} and/or ${_user_pubkey}) - this explains every file failing for this account, independent of sharing" + _user_lines+=("${_path}"$'\t'"${_class}: ${_detail}") + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$_path" "${_owner:-?}" "no" "n/a" "n/a" "$_class" "$_orig_error" >> "$diag_report_file" + continue + fi + + if [[ "$_status" != "OK" ]] ; then + (( _user_owner_unresolved++ )) + _class="OWNER_UNRESOLVED" + _detail="could not determine current owner/path via the Files API (${_status}: ${_odetail})" + _user_lines+=("${_path}"$'\t'"${_class}: ${_detail}") + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$_path" "${_owner:-?}" "yes" "n/a" "n/a" "$_class" "$_orig_error" >> "$diag_report_file" + continue + fi + + _owner_rel="${_ownerpath#/${_owner}/files/}" + + if [[ "$_owner_rel" = "$_ownerpath" ]] ; then + # Prefix did not match - path shape was not what we expected. + (( _user_owner_unresolved++ )) + _class="OWNER_UNRESOLVED" + _detail="owner path '${_ownerpath}' did not have the expected '/${_owner}/files/...' shape" + _user_lines+=("${_path}"$'\t'"${_class}: ${_detail}") + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$_path" "${_owner:-?}" "yes" "n/a" "n/a" "$_class" "$_orig_error" >> "$diag_report_file" + continue + fi + + # - NOTE: an earlier version of this script also required a + # - standalone 'fileKey' file next to the shareKey and treated + # - its absence as a failure (NO_FILEKEY). That was WRONG: in + # - the default (non-legacy, non-masterkey) key storage mode, + # - Nextcloud's Encryption module never writes a plain 'fileKey' + # - file at all - the file key only ever exists ENCRYPTED, once + # - per user with access, as '.shareKey'; getFileKey() + # - reconstructs it by decrypting one of those. A uniform + # - 'fileKey missing' result across many unrelated accounts (as + # - seen in practice) is the tell that this check was testing + # - for something that legitimately never exists here, not a + # - real defect - so it is now purely informational and never + # - drives the classification. + # - + _filekey_path="${DATA_DIR}/${_owner}/files_encryption/keys/files/${_owner_rel}/OC_DEFAULT_MODULE/fileKey" + _sharekey_path="${DATA_DIR}/${_owner}/files_encryption/keys/files/${_owner_rel}/OC_DEFAULT_MODULE/${_user}.shareKey" + + if [[ -f "$_filekey_path" ]] ; then + _has_filekey="yes" + else + _has_filekey="no (informational only - see note above; NOT used to classify)" + fi + if [[ -f "$_sharekey_path" ]] ; then + _has_sharekey="yes" + else + _has_sharekey="no" + fi + + if [[ "$_has_sharekey" = "no" ]] ; then + (( _user_no_sharekey++ )) + _class="NO_SHAREKEY" + _detail="no shareKey exists for '${_user}' on this file (${_sharekey_path}) - '${_user}' was probably never (re-)granted a usable key for this file" + else + (( _user_key_material_present++ )) + _class="KEY_MATERIAL_PRESENT" + _detail="'${_user}.shareKey' exists on disk (${_sharekey_path}) - the failure is a genuine decrypt/mismatch problem: '${_user}'s current private key cannot decrypt this existing shareKey (owner: ${_owner}$([[ "$_owner" != "$_user" ]] && echo ", shared file"))" + fi + + _user_lines+=("${_path}"$'\t'"${_class}: ${_detail}") + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$_path" "$_owner" "yes" "$_has_filekey" "$_has_sharekey" "$_class" "$_orig_error" >> "$diag_report_file" + + done < "$owner_result_tsv" + + if $terminal ; then + echo -e " \033[1;37mAccount ${_user}\033[m" + echo -e " Diagnostiziert.............................: ${_user_selected}" + if [[ $_user_no_own_keypair -gt 0 ]] ; then + echo -e " Kein eigenes Schluesselpaar (NO_OWN_KEYPAIR): \033[1;31m${_user_no_own_keypair}\033[m" + fi + [[ $_user_owner_unresolved -gt 0 ]] && echo -e " Eigentuemer nicht aufloesbar................: \033[33m${_user_owner_unresolved}\033[m" + [[ $_user_no_filekey -gt 0 ]] && echo -e " Datei-Schluessel fehlt (NO_FILEKEY)........: \033[33m${_user_no_filekey}\033[m" + [[ $_user_no_sharekey -gt 0 ]] && echo -e " Freigabe-Schluessel fehlt (NO_SHAREKEY)....: \033[33m${_user_no_sharekey}\033[m" + [[ $_user_key_material_present -gt 0 ]] && echo -e " Schluessel vorhanden, Entschluesseln schlaegt fehl: \033[33m${_user_key_material_present}\033[m" + echo "" + fi + + { + echo "" + for _line in "${_user_lines[@]}" ; do + printf ' %s\n' "$_line" + done + echo "" + echo " Account ${_user}" + echo " Diagnostiziert.............................: ${_user_selected}" + [[ $_user_no_own_keypair -gt 0 ]] && echo " Kein eigenes Schluesselpaar (NO_OWN_KEYPAIR): ${_user_no_own_keypair}" + [[ $_user_owner_unresolved -gt 0 ]] && echo " Eigentuemer nicht aufloesbar................: ${_user_owner_unresolved}" + [[ $_user_no_filekey -gt 0 ]] && echo " Datei-Schluessel fehlt (NO_FILEKEY)........: ${_user_no_filekey}" + [[ $_user_no_sharekey -gt 0 ]] && echo " Freigabe-Schluessel fehlt (NO_SHAREKEY)....: ${_user_no_sharekey}" + [[ $_user_key_material_present -gt 0 ]] && echo " Schluessel vorhanden, Entschluesseln schlaegt fehl: ${_user_key_material_present}" + } >> "$diag_report_file" + + (( total_selected += _user_selected )) + (( total_no_own_keypair += _user_no_own_keypair )) + (( total_owner_unresolved += _user_owner_unresolved )) + (( total_no_sharekey += _user_no_sharekey )) + (( total_no_filekey += _user_no_filekey )) + (( total_key_material_present += _user_key_material_present )) + + unset _user_no_own_keypair _user_owner_unresolved _user_no_sharekey _user_no_filekey _user_key_material_present _user_lines + +done + + +{ + echo "" + echo "" + echo "==================================================================" + echo " Gesamtergebnis" + echo "==================================================================" + echo "" + echo "Ausgewaehlte Accounts.......................: ${#selected_user_arr[@]}" + [[ $total_failed_users -gt 0 ]] && echo "Accounts mit Diagnose-Fehler................: ${total_failed_users}" + echo "Diagnostiziert insgesamt.....................: ${total_selected}" + [[ $total_no_own_keypair -gt 0 ]] && echo "Kein eigenes Schluesselpaar (NO_OWN_KEYPAIR): ${total_no_own_keypair}" + [[ $total_owner_unresolved -gt 0 ]] && echo "Eigentuemer nicht aufloesbar.................: ${total_owner_unresolved}" + [[ $total_no_filekey -gt 0 ]] && echo "Datei-Schluessel fehlt (NO_FILEKEY).........: ${total_no_filekey}" + [[ $total_no_sharekey -gt 0 ]] && echo "Freigabe-Schluessel fehlt (NO_SHAREKEY)......: ${total_no_sharekey}" + [[ $total_key_material_present -gt 0 ]] && echo "Schluessel vorhanden, Entschluesseln schlaegt fehl: ${total_key_material_present}" + echo "" + echo "READ-ONLY: es wurde nichts entschluesselt, geschrieben oder veraendert." +} >> "$diag_report_file" + +blank_line + +if $terminal ; then + echo -e "\033[37m\033[1mErgebnis\033[m" + echo "" + echo -e " Ausgewaehlte Accounts.......................: ${#selected_user_arr[@]}" + [[ $total_failed_users -gt 0 ]] && echo -e " Accounts mit Diagnose-Fehler................: \033[1;31m${total_failed_users}\033[m" + echo -e " Diagnostiziert insgesamt.....................: ${total_selected}" + [[ $total_no_own_keypair -gt 0 ]] && echo -e " Kein eigenes Schluesselpaar (NO_OWN_KEYPAIR): \033[1;31m${total_no_own_keypair}\033[m" + [[ $total_owner_unresolved -gt 0 ]] && echo -e " Eigentuemer nicht aufloesbar.................: \033[33m${total_owner_unresolved}\033[m" + [[ $total_no_filekey -gt 0 ]] && echo -e " Datei-Schluessel fehlt (NO_FILEKEY).........: \033[33m${total_no_filekey}\033[m" + [[ $total_no_sharekey -gt 0 ]] && echo -e " Freigabe-Schluessel fehlt (NO_SHAREKEY)......: \033[33m${total_no_sharekey}\033[m" + [[ $total_key_material_present -gt 0 ]] && echo -e " Schluessel vorhanden, Entschluesseln schlaegt fehl: \033[33m${total_key_material_present}\033[m" + echo "" + echo -e " Diagnose-Report...............................: reports/$(basename "${diag_report_file}")" + echo "" + info "Lies zuerst die Account-Zusammenfassung: 'NO_OWN_KEYPAIR' bei (fast) allen Dateien eines Accounts heisst, das eigene Schluesselpaar dieses Accounts ist das Problem - nicht die einzelnen Dateien." +fi + +clean_up 0 diff --git a/occ_scan_bad_signature.sh b/occ_scan_bad_signature.sh new file mode 100755 index 0000000..b626dd5 --- /dev/null +++ b/occ_scan_bad_signature.sh @@ -0,0 +1,887 @@ +#!/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" + +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) + + +# ============= +# --- Some functions +# ============= + +usage() { + + [[ -n "$1" ]] && error "$1" + + [[ $terminal ]] && echo -e " +\033[1mUsage:\033[m + + $(basename $0) -s + +\033[1mDescription\033[m + + Scans the encrypted files of one, several or all Nextcloud accounts + and reports every file that fails to decrypt (e.g. 'Bad Signature' + errors thrown by Server-Side Encryption). Every file is read once + and its content discarded - the script is READ-ONLY and changes, + moves or deletes nothing. + + You will be asked interactively which account(s) to scan: + + - a blank separated list of account names + - or 'all' to scan every existing account + +\033[1mOptions\033[m + + -s + The site of the nextcloud instance. + +\033[1mExample:\033[m + + Runs this script on system 'cloud-01.oopen.de' + + $(basename $0) -s cloud-01.oopen.de +" + + clean_up 1 + +} + + +clean_up() { + + # Perform program exit housekeeping + [[ -n "$scan_php_file" ]] && rm -f "$scan_php_file" 2> /dev/null + rm -rf "$LOCK_DIR" + blank_line + exit $1 +} + +is_number() { + + return $(test ! -z "${1##*[!0-9]*}" > /dev/null 2>&1); + + # - also possible + # - + #[[ ! -z "${1##*[!0-9]*}" ]] && return 0 || return 1 + #return $([[ ! -z "${1##*[!0-9]*}" ]]) +} + +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 +} +echo_funde() { + if $terminal ; then + echo -e "\033[75G[ \033[33m\033[1mFunde!\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 +} + +blank_line() { + if $terminal ; then + echo "" + fi +} + + + +# - Running in a terminal? +# - +if [[ -t 1 ]] ; then + terminal=true +else + terminal=false +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 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 +# ------------- +while getopts hs: opt ; do + case $opt in + h) usage ;; + s) WEBSITE=$OPTARG ;; + \?) usage + esac +done + +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)" + + +# ============= +# --- 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 "Scan encrypted files for \033[1mBad Signature\033[m errors on system \033[1m${WEB_BASE_DIR}\033[m" + echo -e "\033[1m + Read-only: every file is decrypted once and the content discarded, + reporting every file that fails Server-Side Encryption's signature + check. Nothing is changed, moved or deleted.\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 + + +# ============= +# --- Determine existing accounts +# ============= + +blank_line +echononl " Get list of current accounts.." + +mapfile -t unsorted_account_arr < <(su -c "${PHP_BIN} ${INSTALL_DIR}/occ user:list" -s /bin/bash $HTTP_USER 2> ${log_file} | awk -F'[:[:space:]]+' '{print $3}' 2>> ${log_file} ) + +if [[ $? -gt 0 ]] || [[ -s "${log_file}" ]] ; then + echo_failed + fatal "$(cat $log_file)" +else + echo_ok + > "$log_file" +fi + +# - Sort array +# - +IFS=$'\n' account_arr=($(sort <<<"${unsorted_account_arr[*]}")) +IFS=$CUR_IFS + + +# ============= +# --- Ask which account(s) to scan +# ============= + +echo "" +echo "" +echo -e "\033[32m-----\033[m" +echo "" +echo -e " Existing accounts on \033[1m${WEBSITE}\033[m\n" +echo -en "\033[33m" +printf " %s\n" "${account_arr[@]}" +echo -en "\033[m" +blank_line + +echo "" +echo "" +echo -e "\033[32m-----\033[m" +echo "" +echo -e " Which account(s) would you like to scan for 'Bad Signature' errors?" +echo "" +echo " - enter a list separated by spaces" +echo -e " - or type \033[33mall\033[m to scan every existing account" +echo "" +SELECTED_USERS= +while [[ -z "$(trim ${SELECTED_USERS})" ]] ; do + + echononl " Account(s) to scan: " + read SELECTED_USERS + + if [[ -z "$(trim ${SELECTED_USERS})" ]] ; then + echo -e "\n\t\033[33m\033[1mEntry must not be empty. Type \033[m\033[1mall\033[33m to scan every account.\033[m\n" + continue + fi + + if [[ "${SELECTED_USERS,,}" = "all" ]] ; then + selected_user_arr=("${account_arr[@]}") + break + fi + + IFS=' ' read -ra unsorted_selected_user_arr <<< "${SELECTED_USERS}" + + _all_valid=true + for _user in "${unsorted_selected_user_arr[@]}" ; do + if ! containsElement "${_user}" "${account_arr[@]}" ; then + echo -e "\n\t\033[33m\033[1m No account \033[m\033[1m${_user}\033[33m present!\033[m - Try again..\n" + SELECTED_USERS="" + _all_valid=false + break + fi + done + + $_all_valid || continue + + # - Sort array + # - + IFS=$'\n' selected_user_arr=($(sort <<<"${unsorted_selected_user_arr[*]}")) + IFS=$CUR_IFS + +done + + +# ============= +# --- Write out the (embedded) PHP scan script into the Nextcloud +# --- install directory - it needs to sit next to 'occ' since it +# --- bootstraps Nextcloud the same way occ does. +# ============= + +scan_php_file="${INSTALL_DIR}/.occ_scan_bad_signature_$$.php" + +cat > "$scan_php_file" <<'PHP_SCAN_EOF' + [--before=YYYY-MM-DD] [--after=YYYY-MM-DD] + * + * Ausgabe (TSV, nach STDOUT): + * user \t path \t size_bytes \t mtime \t error + * Eine Zeile pro Datei, bei der das Lesen fehlgeschlagen ist. + * Fortschritts-/Debug-/Zusammenfassungsmeldungen gehen nach STDERR. + */ + +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +fwrite(STDERR, "DEBUG: Skript gestartet, PID=" . getmypid() . "\n"); + +// Faengt fatale, NICHT als Throwable auftretende Fehler ab (z.B. Memory +// Limit exhausted), die sonst komplett stumm bleiben wuerden. +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"); + } else { + fwrite(STDERR, "DEBUG: Shutdown ohne erkannten Fatal-Error.\n"); + } +}); + +// WICHTIG: Muss VOR dem require von lib/base.php gesetzt werden. +// Ohne diese Konstante fuehrt lib/base.php am Ende automatisch +// OC::handleRequest() aus (als waere dies ein normaler HTTP-Request). +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__); + +fwrite(STDERR, "DEBUG: vor require lib/base.php\n"); +require_once __DIR__ . '/lib/base.php'; +fwrite(STDERR, "DEBUG: nach require lib/base.php - Bootstrap abgeschlossen\n"); + +chdir($oldWorkingDir); + +if (function_exists('posix_getuid') && posix_getuid() === 0) { + fwrite(STDERR, "Bitte NICHT als root ausfuehren, sondern als Webserver-User, z.B.:\n"); + fwrite(STDERR, " sudo -u www-data php " . __FILE__ . "\n"); + exit(1); +} + +// Ab hier alles in einem Catch-All, damit ein Throwable garantiert HIER +// landet und nicht beim (auf STDOUT/STDERR ggf. still bleibenden) +// globalen Nextcloud-Handler. +try { + fwrite(STDERR, "DEBUG: hole UserManager/RootFolder ...\n"); + + // Argumente einlesen: erstes nicht-Options-Argument = Benutzername + // (optional, sonst alle Benutzer). --before=YYYY-MM-DD prueft nur + // Dateien, die VOR diesem Datum zuletzt geaendert wurden - spart bei + // grossen Accounts viel Zeit, sobald der Cutoff-Zeitpunkt bekannt ist. + $onlyUser = null; + $beforeTs = null; + $afterTs = null; + foreach (array_slice($argv, 1) as $arg) { + if (str_starts_with($arg, '--before=')) { + $beforeTs = strtotime(substr($arg, 9)); + } elseif (str_starts_with($arg, '--after=')) { + $afterTs = strtotime(substr($arg, 8)); + } elseif ($onlyUser === null) { + $onlyUser = $arg; + } + } + if ($beforeTs !== null) { + fwrite(STDERR, "DEBUG: Filter --before " . date('Y-m-d', $beforeTs) . "\n"); + } + if ($afterTs !== null) { + fwrite(STDERR, "DEBUG: Filter --after " . date('Y-m-d', $afterTs) . "\n"); + } + + // Neuere Nextcloud-Versionen haben die alten Komfort-Methoden wie + // getUserManager()/getRootFolder() auf \OC::$server entfernt - der + // unterstuetzte Weg ist jetzt der PSR-11-Container ->get(...). + $userManager = \OC::$server->get(\OCP\IUserManager::class); + $rootFolder = \OC::$server->get(\OCP\Files\IRootFolder::class); + fwrite(STDERR, "DEBUG: UserManager/RootFolder OK\n"); + + $total = 0; + $skipped = 0; + $bad = 0; + $usersScanned = 0; + + echo "user\tpath\tsize_bytes\tmtime\terror\n"; + flush(); + + /** + * Rekursiv durch einen Ordner gehen und jede Datei vollstaendig lesen + * (ausser sie liegt ausserhalb von --before/--after). + */ + $scanFolder = function (\OCP\Files\Folder $folder, string $uid) use (&$scanFolder, &$total, &$skipped, &$bad, $beforeTs, $afterTs) { + foreach ($folder->getDirectoryListing() as $node) { + if ($node instanceof \OCP\Files\Folder) { + $scanFolder($node, $uid); + continue; + } + if (!($node instanceof \OCP\Files\File)) { + continue; + } + + $mtimeRaw = $node->getMTime(); + if (($beforeTs !== null && $mtimeRaw >= $beforeTs) || ($afterTs !== null && $mtimeRaw <= $afterTs)) { + $skipped++; + continue; + } + + $total++; + $path = $node->getPath(); + + try { + $stream = $node->fopen('r'); + if ($stream === false) { + throw new \RuntimeException('fopen lieferte false'); + } + while (!feof($stream)) { + $chunk = fread($stream, 8 * 1024 * 1024); + if ($chunk === false) { + throw new \RuntimeException('fread schlug mitten im Stream fehl'); + } + } + fclose($stream); + } catch (\Throwable $e) { + $bad++; + $mtime = date('c', $node->getMTime()); + $msg = str_replace(["\t", "\n", "\r"], ' ', $e->getMessage()); + echo "$uid\t$path\t{$node->getSize()}\t$mtime\t" . get_class($e) . ": $msg\n"; + flush(); + } + } + }; + + $scanUser = function (\OCP\IUser $user) use (&$scanFolder, $rootFolder, &$usersScanned) { + $uid = $user->getUID(); + fwrite(STDERR, "Scanne Benutzer: $uid ...\n"); + + \OC_Util::setupFS($uid); + + try { + $userFolder = $rootFolder->getUserFolder($uid); + } catch (\Throwable $e) { + fwrite(STDERR, " Ueberspringe $uid: kann User-Folder nicht laden (" . $e->getMessage() . ")\n"); + return; + } + + $scanFolder($userFolder, $uid); + $usersScanned++; + }; + + if ($onlyUser !== null) { + fwrite(STDERR, "DEBUG: suche Benutzer '$onlyUser' ...\n"); + $user = $userManager->get($onlyUser); + if ($user === null) { + fwrite(STDERR, "Benutzer '$onlyUser' nicht gefunden.\n"); + exit(1); + } + fwrite(STDERR, "DEBUG: Benutzer gefunden, starte Scan ...\n"); + $scanUser($user); + } else { + $userManager->callForAllUsers($scanUser); + } + + fwrite(STDERR, "\nFertig. Benutzer gescannt: $usersScanned, Dateien geprueft: $total, uebersprungen (Filter): $skipped, fehlgeschlagen: $bad\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_SCAN_EOF + +chmod 644 "$scan_php_file" 2> /dev/null + +mkdir -p "$report_dir" 2> /dev/null +report_file="${report_dir}/bad_signature_${WEBSITE}_${run_date}.tsv" + +{ + echo "==================================================================" + echo " Bad-Signature-Scan ${WEBSITE} ${run_date}" + echo "==================================================================" + echo "" + echo -e "user\tpath\tsize_bytes\tmtime\terror" +} > "$report_file" + + +if $terminal ; then + echo "" + echo -e "\033[1;32mStarting Script for \033[1;37m${WEBSITE}\033[m" + echo "" + echo -e " Cloud instance to be scanned.........: $WEBSITE" + echo "" + echo -e " Current version of nextcloud.........: $CURRENT_VERSION" + echo "" + echo -e " Web base directory...................: $WEB_BASE_DIR" + echo -e " Install directory....................: $INSTALL_DIR" + echo -e " Data directory.......................: $DATA_DIR" + echo "" + echo -e " Webserver user.......................: $HTTP_USER" + echo -e " Webserver group......................: $HTTP_GROUP" + echo "" + echo -e " PHP command..........................: $PHP_BIN" + echo "" + echo -e " Account(s) to scan...................: \033[33m${selected_user_arr[*]}\033[m" + echo "" + echo -e " Report file...........................: $report_file" + echo "" + + 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 scan encrypted files for each selected account on \033[1;37m$WEBSITE \033[m" + else + fatal "Abort by user request - Answer as not 'YES'" + fi +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 + +declare -i total_checked=0 +declare -i total_bad=0 +declare -i total_failed_users=0 + +for _user in "${selected_user_arr[@]}" ; do + + user_tsv="${LOCK_DIR}/scan_${_user}.tsv" + + # - Gut sichtbarer Account-Abschnitt im Reportfile - wird IMMER + # - angelegt, auch wenn der Scan fuer diesen Account fehlschlaegt. + # - + _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" + } >> "$report_file" + + echononl " Scanning account \033[1;37m${_user}\033[m.." + su -c "$PHP_BIN $scan_php_file $_user" -s /bin/bash $HTTP_USER > "$user_tsv" 2> "$log_file" + _rc=$? + + if [[ $_rc -ne 0 ]]; then + echo_failed + error "$(cat "$log_file")" + echo " [ FEHLER beim Scan - siehe Server-Log ]" >> "$report_file" + (( total_failed_users++ )) + continue + fi + + # - Kopfzeile weglassen und im Account-Abschnitt anhaengen + # - + tail -n +2 "$user_tsv" >> "$report_file" + + _user_bad=$(tail -n +2 "$user_tsv" | wc -l) + (( total_bad += _user_bad )) + + # - Scan lief durch: bei Funden gelb hervorheben statt schlichtem 'ok' + # - + if [[ $_user_bad -gt 0 ]] ; then + echo_funde + else + echo_ok + fi + + # - Leerzeile NACH der 'Scanning account ..'-Zeile, vor der + # - Account-Statistik. + # - + $terminal && echo "" + + # - Kennzahlen aus der STDERR-Zusammenfassung herausziehen + # - + _summary="$(grep -E '^Fertig\.' "$log_file")" + _checked="$(echo "$_summary" | grep -oE 'Dateien geprueft: [0-9]+' | grep -oE '[0-9]+')" + _skipped="$(echo "$_summary" | grep -oE 'uebersprungen \(Filter\): [0-9]+' | grep -oE '[0-9]+')" + [[ -z "$_skipped" ]] && _skipped="0" + + if [[ "$_checked" =~ ^[0-9]+$ ]] ; then + (( total_checked += _checked )) + _pct="$(calc_percent "$_user_bad" "$_checked")" + else + _checked="?" + _pct="?" + fi + + # - Ergebnis-Block pro Account - gleiches Looking wie das + # - Gesamtergebnis am Ende (Konsole + Reportfile) + # - + if $terminal ; then + echo -e " \033[1;37mAccount ${_user}\033[m" + echo -e " Gescannte Dateien.......................: ${_checked}" + echo -e " Uebersprungene Dateien...................: ${_skipped}" + if [[ $_user_bad -gt 0 ]] ; then + echo -e " Dateien mit 'Bad Signature'..............: \033[1;33m${_user_bad} (${_pct} %)\033[m" + else + echo -e " Dateien mit 'Bad Signature'..............: ${_user_bad} (${_pct} %)" + fi + # - Leerzeile HINTER der Account-Statistik, damit sie nicht am + # - 'Scanning account ..' des naechsten Accounts klebt. + # - + echo "" + fi + + { + echo " Account ${_user}" + echo " Gescannte Dateien.......................: ${_checked}" + echo " Uebersprungene Dateien...................: ${_skipped}" + echo " Dateien mit 'Bad Signature'..............: ${_user_bad} (${_pct} %)" + } >> "$report_file" + +done + +total_pct="$(calc_percent "$total_bad" "$total_checked")" + +# - Gesamtergebnis ans Ende des Reportfiles - gleiches Looking wie +# - die Ergebnis-Bloecke pro Account +# - +{ + echo "" + echo "" + echo "==================================================================" + echo " Gesamtergebnis" + echo "==================================================================" + echo "" + echo "Gescannte Accounts.........................: ${#selected_user_arr[@]}" + [[ $total_failed_users -gt 0 ]] && echo "Accounts mit Scan-Fehler...................: ${total_failed_users}" + echo "Gescannte Dateien insgesamt.................: ${total_checked}" + echo "Dateien mit 'Bad Signature' insgesamt.......: ${total_bad} (${total_pct} %)" +} >> "$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 Scan-Fehler...................: \033[1;31m${total_failed_users}\033[m" + echo -e " Gescannte Dateien insgesamt.................: ${total_checked}" + echo -e " Dateien mit 'Bad Signature' insgesamt.......: \033[1;31m${total_bad} (${total_pct} %)\033[m" + echo -e " Report-Datei.................................: ${report_file}" + echo "" +fi + +clean_up 0 diff --git a/recover_bad_signature.sh b/recover_bad_signature.sh index 5b4c90b..d4262fc 100755 --- a/recover_bad_signature.sh +++ b/recover_bad_signature.sh @@ -813,14 +813,8 @@ 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}/ - and validate them" - echo "" - 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" + echo -e " \033[1m[1] Recovery\033[m - decrypt bad-signature files (temporarily skips the signature check), write them under ${DEFAULT_RECOVERY_BASE_DIR}/ 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 @@ -1425,9 +1419,9 @@ for _user in "${selected_user_arr[@]}" ; do _local="${user_out_dir}/${_rel}" if [[ -f "$_local" ]] ; then _b=$(stat -c%s "$_local" 2> /dev/null) - echo -e "${_p}\t${_b:-0}\t${_osize}\tOK" + printf '%s\t%s\t%s\t%s\n' "$_p" "${_b:-0}" "$_osize" "OK" else - echo -e "${_p}\t0\t${_osize}\tNOT_RECOVERED: no file found under ${user_out_dir} (run without -V first)" + 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" @@ -1484,7 +1478,13 @@ for _user in "${selected_user_arr[@]}" ; do (( _user_read_errors++ )) fi - echo -e "${_path}\t${_recbytes}\t${_origsize}\t${_status}\t${_val_class}\t${_val_detail}" >> "$recovery_report_file" + # 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" @@ -1522,7 +1522,7 @@ for _user in "${selected_user_arr[@]}" ; do echo " Datenmuell (ungueltig) - Account ${_user} (${_user_invalid})" echo " ------------------------------------------------------------------" for _line in "${_user_invalid_lines[@]}" ; do - echo -e " ${_line}" + printf ' %s\n' "$_line" done fi if [[ $_user_unverified -gt 0 ]] ; then @@ -1531,7 +1531,7 @@ for _user in "${selected_user_arr[@]}" ; do echo " Nicht pruefbar - Account ${_user} (${_user_unverified})" echo " ------------------------------------------------------------------" for _line in "${_user_unverified_lines[@]}" ; do - echo -e " ${_line}" + printf ' %s\n' "$_line" done fi } >> "$recovery_report_file" diff --git a/recreate_bad_signature.sh b/recreate_bad_signature.sh new file mode 100755 index 0000000..2a7a6dc --- /dev/null +++ b/recreate_bad_signature.sh @@ -0,0 +1,2001 @@ +#!/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" + +# - Source of the files this script writes back into Nextcloud: the +# - already-recovered, already-validated copies produced by a PRIOR +# - recover_bad_signature.sh run (same default as recover/restore - +# - see the comments there). +# - +DEFAULT_RECOVERY_BASE_DIR="/var/nc-recovery" + +# - Before this script DELETES a file's current (still broken) node, +# - it keeps a raw, byte-for-byte copy of the CURRENT on-disk +# - ciphertext AND the current encryption-key directory for that file +# - (fileKey/shareKey files) in a separate directory - both bypass +# - Nextcloud/its encryption layer entirely, same rationale as +# - restore_bad_signature.sh's backup. This is a SEPARATE backup +# - location from restore_bad_signature.sh's, because this script +# - backs up the CURRENT state (which may already differ from what +# - restore_bad_signature.sh backed up earlier, if a restore attempt +# - already touched this file once). +# - +DEFAULT_RECREATE_BACKUP_BASE_DIR="/var/nc-recreate-backup" + +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) + + +# ============= +# --- Some functions +# ============= + +usage() { + + [[ -n "$1" ]] && error "$1" + + [[ $terminal ]] && echo -e " +\033[1mUsage:\033[m + + $(basename $0) -s + +\033[1mDescription\033[m + + For files where restore_bad_signature.sh's normal OVERWRITE fails + with a key-material error ('OCA\\Encryption\\Exceptions\\MultiKeyDecryptException' + or 'Cannot decrypt this file, probably this is a shared file...') - + this script takes a DIFFERENT approach: instead of overwriting the + existing (broken) file in place, which forces Nextcloud to try to + reuse/decrypt the EXISTING file key first (exactly what fails), + this DELETES the broken node entirely and creates a brand-new file + at the same path. A brand-new file never needs to decrypt any old + key material at all - Nextcloud just generates a fresh symmetric + key and wraps it fresh for the current access list. This sidesteps + the whole class of key-mismatch errors that plague an in-place + overwrite on this instance. + + IMPORTANT - read before using: + + - This is more invasive than restore_bad_signature.sh. Deleting + a file's node and recreating it gives the file a NEW internal + file id. Any existing Nextcloud SHARES, comments, tags, and + the version history of that specific file are tied to the OLD + file id and are lost/broken by this - they are NOT + automatically recreated. A share would need to be set up again + manually afterwards if still needed. + - The old (broken) file is removed with a plain filesystem + unlink() on the raw ciphertext - NOT via Nextcloud's normal + delete/trash API, and NOT via the storage layer's own + unlink() either. Both of those were tried and, in a real run + on this instance, both failed with the exact same key-material + error this script exists to work around: it turns out that + EVERY operation the encryption storage wrapper performs on one + of these files - reading, writing, or deleting alike - first + needs to establish a valid file-key context, which can never + succeed for these files. A plain filesystem delete never goes + through that wrapper (or any Nextcloud code) at all, so it + sidesteps the problem completely. Consequence: the file does + NOT end up in "Deleted files" and cannot be restored from the + Nextcloud trash bin afterwards - both the ciphertext and the + key directory are already backed up to plain filesystem + locations (see below) before the file is touched, independent + of the trash bin, and that backup is verified (size compared) + before anything is deleted - if it cannot be verified, the + file is left untouched and reported instead. + - The OLD key directory (the file's shareKey/fileKey entries + under files_encryption/keys/files/...) is, after being backed + up, ALSO physically removed - not just the ciphertext. This + turned out to be necessary: even with the ciphertext already + gone, Nextcloud's encryption wrapper still finds the old key + material at that path when creating the new file, tries to + reuse it, and fails with the same decrypt error (confirmed in + a real run). Only with no key material left at the path at all + is Nextcloud forced to generate a completely fresh file key. + - Because of this, every target file is checked (read-only, + before anything is touched) for currently active Nextcloud + shares. A file WITH active shares is SKIPPED by default + (status SHARES_FOUND_SKIPPED in the report, share details + listed) - pass '-f' to include such files anyway, only once + you have decided that is acceptable. + - Only files whose restore report shows a WRITE_ERROR with one + of the two known key-material error signatures are ever + considered - this never touches a file that simply hasn't + been tried yet, or failed for an unrelated reason. + - Every candidate file is re-validated (against the current + recovered local copy) right before being touched - the same + safety check restore_bad_signature.sh performs. + - Before deleting anything, BOTH the current on-disk ciphertext + AND the current encryption-key directory (fileKey/shareKey + files) for that file are copied byte-for-byte to a separate + backup directory (default ${DEFAULT_RECREATE_BACKUP_BASE_DIR}/, + override with RECREATE_BACKUP_BASE_DIR in the conf file) - + this is a plain filesystem copy, independent of Nextcloud, + and is a SEPARATE, additional backup on top of whatever + restore_bad_signature.sh already backed up earlier. + - After creating the new file, it is immediately read back + through the NORMAL Nextcloud read path and compared + byte-for-byte (SHA-256) against the source. + - This does NOT touch 'encryption_skip_signature_check' and does + NOT decrypt anything itself - it only reads the already- + recovered plaintext copies under ${DEFAULT_RECOVERY_BASE_DIR}/ + (override with RECOVERY_BASE_DIR). + + Unless '-n' is given on the command line, you will be asked + interactively whether to run a dry-run (nothing is deleted, backed + up or written, just reports what would happen) or the real thing. + You will then be asked which existing restore report (produced by + restore_bad_signature.sh) to use, and then which account(s) from + its WRITE_ERROR/key-material entries to process: + + - a blank separated list of account names + - or 'all' to attempt every account found in the report + +\033[1mOptions\033[m + + -s + The site of the nextcloud instance. + + -n + Dry-run: goes through selection, re-validation, the share check + and reporting, but does NOT back up, delete, create or verify + anything. Passing it here skips the interactive mode question + mentioned above. + + -f + Force: also process files that currently have active Nextcloud + shares (normally skipped - see IMPORTANT above). Use only once + you have accepted that those shares will break. + +\033[1mExample:\033[m + + Runs this script on system 'cloud-01.oopen.de' + + $(basename $0) -s cloud-01.oopen.de + + Dry-run only, nothing is touched: + + $(basename $0) -n -s cloud-01.oopen.de +" + + clean_up 1 + +} + + +clean_up() { + + # Perform program exit housekeeping + [[ -n "$recreate_php_file" ]] && rm -f "$recreate_php_file" 2> /dev/null + rm -rf "$LOCK_DIR" + blank_line + exit $1 + +} + +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 file - IDENTICAL +## - to (and must be kept in sync with) validate_recovered_file() and +## - its helpers in recover_bad_signature.sh / restore_bad_signature.sh. +## - Duplicated here on purpose - sourcing would also execute that +## - script's own top-level flow. 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) + local _head + _head="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" + if [[ "$_head" = "89504e470d0a1a0a" ]] ; then + echo "VALID|PNG signature ok" + else + echo "INVALID|PNG signature missing" + fi + ;; + gif) + local _head6 + _head6="$(head -c6 "$_f" 2> /dev/null)" + if [[ "$_head6" = "GIF87a" || "$_head6" = "GIF89a" ]] ; then + echo "VALID|GIF signature ok" + else + echo "INVALID|GIF signature missing" + fi + ;; + bmp) + if [[ "$(head -c2 "$_f" 2> /dev/null)" = "BM" ]] ; then + echo "VALID|BMP signature ok" + else + echo "INVALID|BMP signature missing" + fi + ;; + tif|tiff) + 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" + else + echo "INVALID|TIFF signature missing" + 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) + 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 + if unzip -tq "$_f" > /dev/null 2>&1 ; then + echo "VALID|zip integrity ok" + else + local _badentry + _badentry="$(trim "$(unzip -t "$_f" 2>&1 | grep -v '^Archive:' | grep -v '^[[:space:]]*$' | head -1)")" + echo "INVALID|zip integrity check failed${_badentry:+ (${_badentry})}" + fi + else + echo "UNVERIFIED|unzip not installed" + fi + ;; + mp4|mov|m4v) + if head -c 64 "$_f" 2> /dev/null | grep -aq "ftyp" ; then + echo "VALID|mp4 ftyp box found" + else + echo "INVALID|mp4 ftyp box not found" + 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) + 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) + 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 '/ found near start" + fi + ;; + css) + 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')" + 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 + ;; + 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) + 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')" + _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) + 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) + 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_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 + if qpdf --check "$_f" > /dev/null 2>&1 ; then + _pdf_check_detail="qpdf --check ok" + return 0 + else + _pdf_check_detail="qpdf --check failed" + return 1 + fi + fi + _pdf_check_detail="PDF header ok (qpdf not installed, no deep check)" + return 0 +} + +_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 + fi + 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 +} + +_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:-}' extension), $_pdf_check_detail" + else + echo "UNVERIFIED|content-detected PDF ('.${_ext:-}' 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:-}') - re-run with that extension to structurally validate, or check manually" + return + ;; + esac + + 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 + + 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:-}', 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 (reads/copies other users' files +# - under the Nextcloud data directory for backups, 'su' into the +# - webserver user to perform the actual delete+recreate). +# - +if [[ "$(id -u)" -ne 0 ]] ; then + fatal "This script must be run as root (it needs to read/copy the Nextcloud data directory for backups and 'su' into the webserver user). 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 + + trap clean_up 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 +# ------------- +DRY_RUN=false +_dry_run_explicit=false +FORCE_SHARED=false + +while getopts hnfs: opt ; do + case $opt in + h) usage ;; + n) DRY_RUN=true; _dry_run_explicit=true ;; + f) FORCE_SHARED=true ;; + s) WEBSITE=$OPTARG ;; + \?) usage + esac +done + + +# ============= +# --- Ask which mode to run in, unless '-n' was already given. The +# --- SAFE choice (dry-run) is the default - this script deletes and +# --- recreates files. +# ============= +if ! $_dry_run_explicit && $terminal ; then + + blank_line + echo -e "\033[37m\033[1mWhich mode should this run use?\033[m" + echo "" + echo -e " \033[1m[1] Dry-run\033[m - go through everything (selection, re-validation, share check, reporting), but delete, back up, create or verify NOTHING" + echo " [2] Real recreate - actually back up, DELETE the broken file, create it fresh and verify each one" + info "Just press Return to use the default: [1] Dry-run." + echo -n " Select mode by number [1]: " + read _mode_choice + + case "$(trim "$_mode_choice")" in + 2) DRY_RUN=false ;; + ""|1) DRY_RUN=true ;; + *) 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) + + 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)" + +[[ -n "$RECOVERY_BASE_DIR" ]] || RECOVERY_BASE_DIR=$DEFAULT_RECOVERY_BASE_DIR +recovery_dir="${RECOVERY_BASE_DIR}/${WEBSITE}" + +[[ -n "$RECREATE_BACKUP_BASE_DIR" ]] || RECREATE_BACKUP_BASE_DIR=$DEFAULT_RECREATE_BACKUP_BASE_DIR +recreate_backup_dir="${RECREATE_BACKUP_BASE_DIR}/${WEBSITE}" + + +# ============= +# --- Some +# ============= + +SYSTEMD_EXISTS=false +systemd=$(which systemd) +systemctl=$(which systemctl) + +if [[ -n "$systemd" ]] || [[ -n "$systemctl" ]] ; then + SYSTEMD_EXISTS=true +fi + +if $terminal ; then + echo "" + echo -e "\033[32m-----\033[m" + echo -e "Delete+recreate still-broken \033[1mBad Signature\033[m files on system \033[1m${WEB_BASE_DIR}\033[m" + echo -e "\033[1m + This DELETES the existing (broken) file node and creates a fresh + one at the same path - more invasive than restore_bad_signature.sh. + Files with active shares are skipped by default (see -f). Both the + current ciphertext and the current key files are backed up first.\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 + + +# ============= +# --- Determine available restore reports (output of +# --- restore_bad_signature.sh) - we need WRITE_ERROR rows, so ONLY +# --- restore_*.tsv reports qualify here (unlike diagnose_share_key.sh, +# --- which also accepts recovery_*.tsv). +# ============= + +mapfile -t _available_reports < <(ls -t "${report_dir}"/restore_*.tsv 2> /dev/null) + +if [[ ${#_available_reports[@]} -eq 0 ]] ; then + fatal "No restore report files (restore_*.tsv) found in '${report_dir}'. Run restore_bad_signature.sh first." +fi + +blank_line +if $terminal ; then + echo -e "\033[37m\033[1mAvailable restore 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))]}" + + +# ============= +# --- Extract "userpath" for every WRITE_ERROR row in the chosen +# --- restore report whose detail matches one of the two known +# --- key-material error signatures. Same section-header state machine +# --- as the sibling scripts. +# ============= + +targets_file="${LOCK_DIR}/targets.tsv" +awk -F'\t' ' + /^-+$/ { + if (state == 0) { state = 1 } + else if (state == 2) { state = 0 } + next + } + { + if (state == 1) { user = $0; state = 2; next } + if (NF == 4 && $1 != "path" && $3 == "WRITE_ERROR") { + if (index($4, "probably this is a shared file") > 0 || index($4, "MultiKeyDecryptException") > 0) { + print user "\t" $1 + } + } + } +' "$chosen_report" > "$targets_file" + +unsorted_account_arr=($(cut -f1 "$targets_file" | sort -u)) +IFS=$'\n' account_arr=($(sort <<<"${unsorted_account_arr[*]}")) +IFS=$CUR_IFS + +if [[ ${#account_arr[@]} -eq 0 ]] ; then + fatal "No key-material WRITE_ERROR entries found in '$(basename "$chosen_report")' - nothing to recreate." +fi + +blank_line +if $terminal ; then + echo -e "\033[37m\033[1mAccounts with key-material WRITE_ERROR entries in this report\033[m" + echo "" + for _a in "${account_arr[@]}" ; do + _a_count=$(awk -F'\t' -v u="$_a" '$1==u' "$targets_file" | wc -l) + echo " - $_a (${_a_count})" + done + echo "" +fi + +echo -n " Account(s) to recreate broken files 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 recreate broken files for (blank separated, or 'all'): " + read _input + +done + + +# ============= +# --- Confirmation +# ============= + +mkdir -p "$report_dir" 2> /dev/null +recreate_report_file="${report_dir}/recreate_${WEBSITE}_${run_date}.tsv" + +if $terminal ; then + echo "" + if $DRY_RUN ; then + echo -e "\033[1;32mStarting DRY-RUN recreate for \033[1;37m${WEBSITE}\033[m" + else + echo -e "\033[1;31m\033[1mStarting REAL recreate (DELETES and recreates files in live Nextcloud storage) for \033[1;37m${WEBSITE}\033[m" + fi + echo "" + echo -e " Cloud instance..........................: $WEBSITE" + echo "" + echo -e " Restore report used......................: $(basename "$chosen_report")" + echo -e " Recovered files read from................: $recovery_dir" + echo -e " Account(s) to process....................: \033[33m${selected_user_arr[*]}\033[m" + $FORCE_SHARED && echo -e " \033[33m-f\033[m given: files with active shares will ALSO be processed (shares will break)." + ! $FORCE_SHARED && echo -e " Files with active shares will be SKIPPED (no -f given)." + echo "" + if $DRY_RUN ; then + echo -e " Nothing will be deleted or written - dry-run only." + else + echo -e " Ciphertext + key backup (before delete)..: $recreate_backup_dir" + echo -e " Files DELETED and recreated at their ORIGINAL path in Nextcloud." + fi + echo -e " Recreate report...........................: reports/$(basename "${recreate_report_file}")" + echo "" + + if $DRY_RUN ; then + info "Dry-run: selection, re-validation and share check run for real, nothing is backed up, deleted, created or verified." + else + warn "This DELETES the existing (broken) file and creates a fresh one at the same path - the file gets a NEW internal id, so any existing shares/comments/version history for it are lost unless already accounted for by the share check above. The current ciphertext AND current key files are backed up first, and every new file is read back and verified afterwards, but please still spot-check a few results yourself." + 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 recreate broken files for each selected account on \033[1;37m$WEBSITE \033[m" + else + fatal "Abort by user request - Answer as not 'YES'" + fi +fi + + +{ + echo "==================================================================" + echo " Bad-Signature-Recreate-Versuch ${WEBSITE} ${run_date}" + echo "==================================================================" + echo "" + echo " Basis-Report (Restore): $(basename "$chosen_report")" + echo " Nur WRITE_ERROR-Eintraege mit Schluessel-Fehlerbild werden bearbeitet." + $DRY_RUN && echo " DRY-RUN: es wurde NICHTS geloescht, gesichert oder veraendert." + ! $DRY_RUN && echo " Chiffretext- und Schluessel-Sicherung (vor dem Loeschen) liegt unter: ${recreate_backup_dir}" + $FORCE_SHARED && echo " -f gegeben: auch Dateien mit aktiven Freigaben wurden bearbeitet." + echo "" + printf 'path\tbytes_written\tstatus\tdetail\n' +} > "$recreate_report_file" + + +# ============= +# --- Write the embedded PHP recreate script +# ============= + +recreate_php_file="${INSTALL_DIR}/.recreate_bad_signature_$$.php" + +cat > "$recreate_php_file" <<'PHP_RECREATE_EOF' + [--dry-run] [--force] + * listFile: TSV "path\tlocalRecoveredFile" per line (no header) + */ + +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +define('OC_CONSOLE', 1); + +/** + * The exception this instance throws for a broken/undecryptable file + * ('Cannot decrypt this file, probably this is a shared file...') is + * only the OUTER, generic wrapper - Nextcloud chains the REAL, much + * more specific cause underneath via getPrevious() (this is exactly + * what a direct manual decrypt test on this instance showed: a + * MultiKeyDecryptException with an underlying openssl RSA-OAEP error). + * Earlier versions of this script only logged the outer message, + * discarding that detail. This walks the full chain so the report + * shows what is ACTUALLY failing, not just the generic headline. + */ +function describe_exception_chain(\Throwable $e): string { + $parts = []; + $current = $e; + $depth = 0; + while ($current !== null && $depth < 6) { + $short = basename(str_replace('\\', '/', get_class($current))); + $loc = basename($current->getFile()) . ':' . $current->getLine(); + $msg = str_replace(["\t", "\n", "\r"], ' ', $current->getMessage()); + $parts[] = "{$short}[{$loc}]: {$msg}"; + $current = $current->getPrevious(); + $depth++; + } + return implode(' <= caused by <= ', $parts); +} + +$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'; +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; + $dataDir = $argv[3] ?? null; + $dryRun = in_array('--dry-run', $argv, true); + $force = in_array('--force', $argv, true); + + if ($uid === null || $listFile === null) { + fwrite(STDERR, "Usage: php recreate_bad_signature.php [--dry-run] [--force]\n"); + exit(1); + } + if (!is_file($listFile)) { + fwrite(STDERR, "Liste nicht gefunden: $listFile\n"); + exit(1); + } + + $rootFolder = \OC::$server->get(\OCP\Files\IRootFolder::class); + \OC_Util::setupFS($uid); + + $shareManager = null; + try { + $shareManager = \OC::$server->get(\OCP\Share\IManager::class); + } catch (\Throwable $e) { + $shareManager = null; + } + + // Used only for direct, API-level diagnostics (see keyManagerNote below) - + // completely separate from and in addition to the raw filesystem + // shareKey probe, so we can tell apart "key file missing/wrong path on + // disk" from "key file present on disk, but Nextcloud's own KeyManager + // singleton does not consider it usable for this uid/path". + $keyManager = null; + try { + $keyManager = \OC::$server->get(\OCA\Encryption\KeyManager::class); + } catch (\Throwable $e) { + $keyManager = null; + } + + $lines = file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + $lines = []; + } + + + echo "path\tbytes_written\tstatus\tdetail\n"; + + foreach ($lines as $line) { + $cols = explode("\t", $line); + $path = $cols[0] ?? ''; + $local = $cols[1] ?? ''; + if ($path === '' || $local === '') { + continue; + } + + if (!is_file($local) || !is_readable($local)) { + echo "$path\t0\tERROR\tlocal recovered file not readable: $local\n"; + flush(); + continue; + } + + $localSize = filesize($local); + $localHash = hash_file('sha256', $local); + $shareKeyNote = ''; + + try { + $node = null; + try { + $node = $rootFolder->get($path); + } catch (\Throwable $e) { + $node = null; + } + + // Share check - read-only, always computed regardless of mode. + $shareSummary = ''; + $shareCount = 0; + if ($node !== null && $shareManager !== null) { + try { + $shares = $shareManager->getSharesByPath($node); + } catch (\Throwable $e) { + $shares = []; + } + if (!empty($shares)) { + $shareCount = count($shares); + $parts = []; + foreach ($shares as $s) { + try { + $parts[] = 'type=' . $s->getShareType() . ' with=' . ($s->getSharedWith() ?: '?'); + } catch (\Throwable $e) { + // ignore malformed share entry + } + } + $shareSummary = implode('; ', $parts); + } + } + + if ($dryRun) { + $note = $shareSummary !== '' ? " (WARNING: has {$shareCount} active share(s) that would break: $shareSummary" . ($force ? ', but -f given' : ' - would be SKIPPED without -f') . ')' : ''; + echo "$path\t$localSize\tDRY_RUN\twould delete existing node and recreate with $localSize bytes (sha256 $localHash)$note\n"; + flush(); + continue; + } + + if ($shareCount > 0 && !$force) { + echo "$path\t0\tSHARES_FOUND_SKIPPED\t{$shareCount} active share(s): $shareSummary - re-run with --force to include anyway (existing shares will break)\n"; + flush(); + continue; + } + + if ($node !== null) { + if (!($node instanceof \OCP\Files\File)) { + echo "$path\t0\tERROR\ttarget exists but is not a regular file\n"; + flush(); + continue; + } + try { + // IMPORTANT: this deliberately does NOT call + // $node->delete() (goes via files_trashbin, which tries + // to copy the encrypted content into trash first) NOR + // $node->getStorage()->unlink() (an earlier version of + // this script tried exactly that - confirmed in a real + // run to fail with the *same* + // DecryptionFailedException/'probably this is a shared + // file' error on all 85 candidates, 0 succeeded). On + // this instance, EVERY operation the encryption storage + // wrapper performs on one of these files - read, write, + // or delete alike - first needs to establish a valid + // file-key context, which can never succeed for these + // files (that is the root problem itself). So the raw + // ciphertext is instead deleted directly on the + // filesystem by the bash wrapper BEFORE this PHP helper + // even runs (see recreate_bad_signature.sh) - a plain + // filesystem unlink() that never goes through any + // Nextcloud/encryption code at all. All that remains to + // do here is drop the now-stale filecache entry (pure + // database metadata, unrelated to the encryption + // wrapper) so the following newFile() does not get + // confused by leftover metadata for the already-removed + // node. + $storage = $node->getStorage(); + $internalPath = $node->getInternalPath(); + $cache = $storage->getCache(); + if ($cache !== null) { + $cache->remove($internalPath); + } + } catch (\Throwable $e) { + echo "$path\t0\tDELETE_ERROR\t" . describe_exception_chain($e) . "\n"; + flush(); + continue; + } + } + + $userFolder = $rootFolder->getUserFolder($uid); + $relative = preg_replace('#^/' . preg_quote($uid, '#') . '/files/#', '', $path); + if ($relative === null || $relative === '') { + $relative = ltrim($path, '/'); + } + + $newNode = $userFolder->newFile($relative); + + // DIAGNOSTIC: this is the exact same call Nextcloud's own + // encryption stream wrapper makes internally right before + // begin()/end() (OC\Files\Stream\Encryption::stream_open(): + // $accessList = $this->file->getAccessList($sharePath), where + // $this->file is NOT a Files\Node but the small helper service + // OC\Encryption\File / OCP\Encryption\IFile) to decide which + // users' public keys to encrypt a fresh share key for. If the + // owner's own uid is missing from this list for a brand-new + // node, end() would never create a share key the owner can + // actually decrypt later - even though *some* share key file + // might still end up on disk. (An earlier version of this + // diagnostic incorrectly called ->getAccessList() directly on + // the Files\Node, which does not have that method at all - that + // was a bug in the diagnostic itself, now fixed.) + $accessListNote = ''; + try { + $encFileHelper = \OC::$server->get(\OCP\Encryption\IFile::class); + $accessListDump = $encFileHelper->getAccessList($path); + $accessListNote = ' [getAccessList() vor putContent(): ' . json_encode($accessListDump) . ']'; + } catch (\Throwable $e) { + $accessListNote = ' [getAccessList()-Fehler: ' . describe_exception_chain($e) . ']'; + } + + // DIAGNOSTIC: the access list is correct (owner is always + // unconditionally included by OC\Encryption\File::getAccessList() + // itself), so a missing-owner theory is ruled out. The remaining + // suspect is the PUBLIC KEY used to encrypt the fresh share key + // at write time: if it does not actually match the REAL usable + // private key for this account (e.g. a stale/cached value, or a + // leftover from the historic keypair-regeneration event), the + // share key would look completely normal (right size, right + // uid) yet never be decryptable by the real owner. Compare the + // public key KeyManager hands us (used for OUR write) against + // the public key freshly re-read directly from disk (bypassing + // any in-memory cache) via the exact same at-rest decryption + // Nextcloud's own key storage uses (OCP\Security\ICrypto). Only + // hashes are logged - public keys are not sensitive, but we + // still avoid printing the raw key material. + $pubKeyNote = ''; + try { + $kmPubKey = $keyManager->getPublicKey($uid); + $kmHash = hash('sha256', (string)$kmPubKey); + } catch (\Throwable $e) { + $kmHash = 'FEHLER: ' . describe_exception_chain($e); + } + try { + $rawPubKeyPath = null; + if ($dataDir !== null && $dataDir !== '') { + $rawPubKeyPath = rtrim($dataDir, '/') . '/' . $uid + . '/files_encryption/OC_DEFAULT_MODULE/' . $uid . '.publicKey'; + } + if ($rawPubKeyPath !== null && is_file($rawPubKeyPath)) { + $rawEncrypted = file_get_contents($rawPubKeyPath); + $crypto = \OC::$server->get(\OCP\Security\ICrypto::class); + $rawDecrypted = $crypto->decrypt($rawEncrypted); + $rawData = json_decode($rawDecrypted, true); + $rawKeyValue = (is_array($rawData) && isset($rawData['key'])) + ? base64_decode($rawData['key']) : ''; + $rawHash = hash('sha256', (string)$rawKeyValue); + } else { + $rawHash = 'DATEI FEHLT (' . ($rawPubKeyPath ?? 'kein dataDir') . ')'; + } + } catch (\Throwable $e) { + $rawHash = 'FEHLER: ' . describe_exception_chain($e); + } + $pubKeyNote = " [PublicKey-Hash via KeyManager=$kmHash, via Rohdatei-direkt=$rawHash]"; + $accessListNote .= $pubKeyNote; + + $shareKeyNote = ''; + $in = fopen($local, 'r'); + if ($in === false) { + throw new \RuntimeException("lokale Datei konnte nicht geoeffnet werden: $local"); + } + $newNode->putContent($in); + if (is_resource($in)) { + fclose($in); + } + + // DIAGNOSTIC: check directly on the physical filesystem + // (bypassing Nextcloud/encryption entirely) whether putContent() + // actually persisted a usable shareKey for THIS uid on the new + // file. This tells us, independent of any further read/decrypt + // attempt, whether the write step itself produced complete key + // material or not. + $shareKeyNote = $accessListNote; + if ($dataDir !== null && $dataDir !== '') { + $shareKeyPath = rtrim($dataDir, '/') . '/' . $uid . '/files_encryption/keys/files/' + . $relative . '/OC_DEFAULT_MODULE/' . $uid . '.shareKey'; + clearstatcache(true, $shareKeyPath); + if (is_file($shareKeyPath)) { + $shareKeyNote .= " [shareKey nach putContent(): vorhanden, " . filesize($shareKeyPath) . " bytes]"; + } else { + $shareKeyNote .= " [shareKey nach putContent(): FEHLT unter $shareKeyPath]"; + } + } + + // DIAGNOSTIC (API-level, independent of the raw filesystem probe + // above): ask Nextcloud's OWN live KeyManager singleton, via its + // public getShareKey()/getFileKey() methods, whether IT considers + // a usable share key / file key to exist for this exact $path and + // $uid - using the *same* path format ("/uid/files/...") that the + // encryption storage wrapper itself uses internally in + // begin()/end() (see OCA\Encryption\Crypto\Encryption:: + // getPathToRealFile()), not our own guessed on-disk layout. This + // tells us whether a mismatch between the two probes points at a + // path/lookup problem inside Nextcloud rather than a missing key + // on disk. + if ($keyManager !== null) { + try { + $apiShareKey = $keyManager->getShareKey($path, $uid); + $apiShareKeyLen = is_string($apiShareKey) ? strlen($apiShareKey) : -1; + } catch (\Throwable $e) { + $apiShareKeyLen = -2; + } + try { + $apiFileKey = $keyManager->getFileKey($path, null); + $apiFileKeyLen = is_string($apiFileKey) ? strlen($apiFileKey) : -1; + } catch (\Throwable $e) { + $apiFileKeyLen = -2; + } + $shareKeyNote .= " [API getShareKey()=" . $apiShareKeyLen + . " bytes, getFileKey()=" . $apiFileKeyLen . " bytes" + . " (-1=leer/keine Daten, -2=Exception)]"; + + // DEEPER DIAGNOSTIC (reflection): getFileKey() internally + // resolves the uid via $this->userSession->getUser()?->getUID() + // (NOT the $uid we pass on the command line) and reads the + // private key via $this->session->getPrivateKey() (an + // OCA\Encryption\Session wrapping the current PHP/CLI + // session, populated only on a real login). Both are private + // properties of KeyManager, so peek at them via Reflection + // to see directly what getFileKey() is actually using + // internally, without guessing from the outside. + try { + $refl = new \ReflectionClass($keyManager); + $userSessionProp = $refl->getProperty('userSession'); + $userSessionProp->setAccessible(true); + $innerUserSession = $userSessionProp->getValue($keyManager); + $innerUser = $innerUserSession->getUser(); + $innerUid = $innerUser !== null ? $innerUser->getUID() : null; + + $sessionProp = $refl->getProperty('session'); + $sessionProp->setAccessible(true); + $innerSession = $sessionProp->getValue($keyManager); + $privateKeySet = $innerSession->isPrivateKeySet(); + $privateKeyLen = -1; + if ($privateKeySet) { + try { + $pk = $innerSession->getPrivateKey(); + $privateKeyLen = is_string($pk) ? strlen($pk) : -1; + } catch (\Throwable $e) { + $privateKeyLen = -2; + } + } + + $shareKeyNote .= " [REFLECT userSession->getUser()=uid:" + . ($innerUid ?? 'NULL') + . " (Skript-uid war: $uid), session isPrivateKeySet()=" + . ($privateKeySet ? 'true' : 'false') + . ", privateKey-Laenge=" . $privateKeyLen . "]"; + } catch (\Throwable $e) { + $shareKeyNote .= " [REFLECT-Fehler: " . describe_exception_chain($e) . "]"; + } + } + + // Verify: re-read through the SAME normal API path. + // + // IMPORTANT CAVEAT (root-caused via source-level analysis of + // OCA\Encryption\KeyManager::getFileKey()): this script never + // establishes a real per-user login - it only ever calls + // \OC_Util::setupFS($uid), never IUserSession::setUser() - so + // $publicAccess = !$this->userSession->isLoggedIn() is TRUE for + // this entire process. In that state, getFileKey() does NOT use + // the real owner's own share key/private key at all: it only + // ever tries the "public link share" system keypair's share + // key (KeyManager::getPublicShareKeyId()). For an ordinary + // PRIVATE file (not shared via a public link, accessList + // "public":false), no such share key was ever created for it - + // so THIS SELF-CHECK CAN NEVER SUCCEED, regardless of whether + // the write itself, and the real owner's own share key on + // disk, are perfectly correct. Confirmed empirically: a + // brand-new control file, written by this exact script via the + // exact same newFile()+putContent() call and never touched + // afterwards, fails this very re-read check the same way - yet + // opens completely normally for the real owner via a real + // browser login. + // + // So a decryption failure with exactly this well-known message, + // happening ONLY here (not earlier, e.g. not during + // putContent() itself), is reported as WRITTEN_UNVERIFIED, not + // WRITE_ERROR: the bytes were written, but this script cannot + // confirm decryptability without a real login - that has to be + // checked manually (real browser login, or an account with a + // known valid password). + clearstatcache(); + try { + $freshNode = $rootFolder->get($path); + $readBack = $freshNode->fopen('r'); + if ($readBack === false) { + echo "$path\t0\tWRITE_ERROR\tputContent() did not throw, but re-read (fopen) failed$shareKeyNote\n"; + flush(); + continue; + } + + $ctx = hash_init('sha256'); + $bytesRead = 0; + while (!feof($readBack)) { + $chunk = fread($readBack, 1048576); + if ($chunk === false) { + break; + } + $bytesRead += strlen($chunk); + hash_update($ctx, $chunk); + } + fclose($readBack); + $readBackHash = hash_final($ctx); + + if ($readBackHash === $localHash && $bytesRead === $localSize) { + echo "$path\t$bytesRead\tOK\tverified: re-read $bytesRead bytes, sha256 matches source ($readBackHash)\n"; + } else { + $hashNote = ($readBackHash === $localHash) ? 'sha256 matches' : 'sha256 MISMATCH'; + echo "$path\t$bytesRead\tVERIFY_MISMATCH\tread back $bytesRead bytes (expected $localSize), $hashNote\n"; + } + } catch (\Throwable $e) { + $msg = $e->getMessage(); + $cls = get_class($e); + if ( + stripos($cls, 'DecryptionFailedException') !== false + && stripos($msg, 'probably this is a shared file') !== false + ) { + echo "$path\t$localSize\tWRITTEN_UNVERIFIED\tDatei wurde geschrieben ($localSize bytes), aber ohne echten Benutzer-Login kann dieses Skript sie nicht selbst entschluesseln/verifizieren (bekannte Einschraenkung von KeyManager::getFileKey() im 'public access'-Modus, siehe Kommentar im Skript) - bitte manuell per echtem Browser-Login pruefen.$shareKeyNote\n"; + } else { + echo "$path\t0\tWRITE_ERROR\t" . describe_exception_chain($e) . " [beim Verifikations-Reread]$shareKeyNote\n"; + } + } + flush(); + } catch (\Throwable $e) { + $note = isset($shareKeyNote) ? $shareKeyNote : ''; + echo "$path\t0\tWRITE_ERROR\t" . describe_exception_chain($e) . "$note\n"; + flush(); + } + } +} 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_RECREATE_EOF + +chmod 644 "$recreate_php_file" + + +# ----- +# - 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 [[ ! -d "$recovery_dir" ]] ; then + fatal "Recovery directory '$recovery_dir' does not exist - run recover_bad_signature.sh first." +fi + +if ! $DRY_RUN ; then + mkdir -p "$recreate_backup_dir" 2> /dev/null +fi + +declare -i total_selected=0 +declare -i total_prevalidation_failed=0 +declare -i total_delete_failed=0 +declare -i total_ok=0 +declare -i total_shares_skipped=0 +declare -i total_written_unverified=0 +declare -i total_write_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" + } >> "$recreate_report_file" + + user_recovery_dir="${recovery_dir}/${_user}" + + declare -i _user_selected=0 + declare -i _user_prevalidation_failed=0 + declare -a _user_prevalidation_failed_lines=() + declare -i _user_delete_failed=0 + declare -a _user_delete_failed_lines=() + + list_file="${LOCK_DIR}/list_${_user}.tsv" + > "$list_file" + + # ============= + # --- Re-validate every target entry against the CURRENT checks + # --- right before touching anything, and (unless dry-run) back up + # --- the CURRENT on-disk ciphertext AND the CURRENT key directory + # --- byte-for-byte first. + # ============= + while IFS=$'\t' read -r _u _path ; do + [[ "$_u" != "$_user" ]] && continue + (( _user_selected++ )) + + _rel="${_path#/${_user}/files/}" + _local="${user_recovery_dir}/${_rel}" + + if [[ ! -f "$_local" ]] ; then + (( _user_prevalidation_failed++ )) + _user_prevalidation_failed_lines+=("${_path}"$'\t'"recovered file missing under ${user_recovery_dir} - re-run recover_bad_signature.sh") + continue + fi + + _val_result="$(validate_recovered_file "$_local" "$_path" "")" + _val_class="${_val_result%%|*}" + _val_detail="${_val_result#*|}" + + if [[ "$_val_class" != "VALID" ]] ; then + (( _user_prevalidation_failed++ )) + _user_prevalidation_failed_lines+=("${_path}"$'\t'"no longer validates as VALID (${_val_class}: ${_val_detail}) - skipped, not recreated") + continue + fi + + if ! $DRY_RUN ; then + _live_ciphertext="${DATA_DIR}/${_user}/files/${_rel}" + _backup_ok=true + + if [[ -f "$_live_ciphertext" ]] ; then + _backup_target="${recreate_backup_dir}/${_user}/${_rel}" + mkdir -p "$(dirname "$_backup_target")" 2> /dev/null + cp -p "$_live_ciphertext" "$_backup_target" 2> /dev/null + if [[ ! -f "$_backup_target" ]] \ + || [[ "$(stat -c%s "$_live_ciphertext" 2> /dev/null)" != "$(stat -c%s "$_backup_target" 2> /dev/null)" ]] ; then + _backup_ok=false + fi + fi + + _key_src="${DATA_DIR}/${_user}/files_encryption/keys/files/${_rel}" + if [[ -d "$_key_src" ]] ; then + _key_backup="${recreate_backup_dir}/${_user}/_keys/${_rel}" + mkdir -p "$(dirname "$_key_backup")" 2> /dev/null + cp -rp "$_key_src" "$_key_backup" 2> /dev/null + [[ -d "$_key_backup" ]] || _backup_ok=false + fi + + if ! $_backup_ok ; then + (( _user_delete_failed++ )) + _user_delete_failed_lines+=("${_path}"$'\t'"Sicherung von Chiffretext/Schluessel-Verzeichnis konnte nicht verifiziert werden - NICHT geloescht, uebersprungen") + continue + fi + + # Delete the raw ciphertext directly on the filesystem, bypassing + # Nextcloud's storage/encryption layer entirely. Every attempt to + # remove this file THROUGH Nextcloud - Node::delete() (goes via + # files_trashbin) and even a plain Storage::unlink() alike - has + # been confirmed (in a real run on this instance) to fail with + # the exact same DecryptionFailedException ('probably this is a + # shared file') that also blocks reading/writing it: the + # encryption wrapper apparently needs to establish a valid + # file-key context before ANY operation on the path, and for + # these files that context can never be established - that is + # the root problem itself. A plain filesystem unlink() bypasses + # that wrapper completely, since it never goes through any + # Nextcloud PHP code at all. The now-stale filecache entry (pure + # database metadata, unrelated to the encryption wrapper) is + # cleaned up afterwards by the PHP helper below. + if [[ -f "$_live_ciphertext" ]] ; then + if ! rm -f -- "$_live_ciphertext" 2> /dev/null ; then + (( _user_delete_failed++ )) + _user_delete_failed_lines+=("${_path}"$'\t'"physisches Loeschen (rm) von '${_live_ciphertext}' fehlgeschlagen") + continue + fi + fi + + # ALSO remove the old key directory itself (not just back it up). + # This turned out to be the real remaining cause of the write + # failures: even after the broken ciphertext is gone, Nextcloud's + # encryption wrapper still finds the OLD shareKey/fileKey material + # sitting at this path when creating the new file, tries to reuse + # it (to decide whether this is an update vs a genuinely new + # file), and fails with the exact same decrypt error - confirmed + # empirically: a real run with the ciphertext already deleted but + # the key directory left in place still failed 85/85 with a + # WRITE_ERROR at the recreate step. Only with NO key material left + # at all at this path is Nextcloud forced to generate a completely + # fresh file key and shareKeys for the new file. + if [[ -d "$_key_src" ]] ; then + if ! rm -rf -- "$_key_src" 2> /dev/null ; then + (( _user_delete_failed++ )) + _user_delete_failed_lines+=("${_path}"$'\t'"physisches Loeschen (rm -rf) des alten Schluessel-Verzeichnisses '${_key_src}' fehlgeschlagen") + continue + fi + fi + fi + + printf '%s\t%s\n' "$_path" "$_local" >> "$list_file" + + done < <(awk -F'\t' -v u="$_user" '$1==u' "$targets_file") + + if [[ $_user_selected -eq 0 ]] ; then + warn "No target entries for account '${_user}' found in the selected report - skipping." + echo " [ keine passenden Eintraege im gewaehlten Report ]" >> "$recreate_report_file" + continue + fi + + user_result_tsv="${LOCK_DIR}/result_${_user}.tsv" + # per-account log file (NOT the shared $log_file, which gets overwritten + # on every loop iteration) so nothing gets lost if something unexpected + # ends up on STDERR for one account while others are still being + # processed. + log_file="${LOCK_DIR}/${script_name%%.*}_${_user}.log" + + if $DRY_RUN ; then + echononl " Dry-run for account \033[1;37m${_user}\033[m (${_user_selected} selected, $(wc -l < "$list_file") to check).." + else + echononl " Recreating account \033[1;37m${_user}\033[m (${_user_selected} selected, $(wc -l < "$list_file") to process).." + fi + + if [[ -s "$list_file" ]] ; then + _php_args=("$recreate_php_file" "$_user" "$list_file" "$DATA_DIR") + $DRY_RUN && _php_args+=("--dry-run") + $FORCE_SHARED && _php_args+=("--force") + su -c "$PHP_BIN ${_php_args[*]}" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file" + _rc=$? + else + echo -e "path\tbytes_written\tstatus\tdetail" > "$user_result_tsv" + _rc=0 + fi + + if [[ $_rc -ne 0 ]]; then + echo_failed + error "$(cat "$log_file")" + echo " [ FEHLER beim Recreate-Lauf - siehe Server-Log ]" >> "$recreate_report_file" + (( total_failed_users++ )) + continue + fi + + echo_done + $terminal && echo "" + + declare -i _user_ok=0 + declare -i _user_shares_skipped=0 + declare -i _user_written_unverified=0 + declare -i _user_write_errors=0 + declare -a _user_shares_skipped_lines=() + declare -a _user_written_unverified_lines=() + declare -a _user_write_error_lines=() + + while IFS=$'\t' read -r _path _bytes _status _detail ; do + + [[ "$_path" = "path" ]] && continue + [[ -z "$_path" ]] && continue + + case "$_status" in + OK|DRY_RUN) (( _user_ok++ )) ;; + SHARES_FOUND_SKIPPED) (( _user_shares_skipped++ )); _user_shares_skipped_lines+=("${_path}"$'\t'"${_detail}") ;; + WRITTEN_UNVERIFIED) (( _user_written_unverified++ )); _user_written_unverified_lines+=("${_path}"$'\t'"${_detail}") ;; + *) (( _user_write_errors++ )); _user_write_error_lines+=("${_path}"$'\t'"${_status}: ${_detail}") ;; + esac + + printf '%s\t%s\t%s\t%s\n' "$_path" "$_bytes" "$_status" "$_detail" >> "$recreate_report_file" + + done < "$user_result_tsv" + + _user_pct_ok="$(calc_percent "$_user_ok" "$_user_selected")" + + if $terminal ; then + echo -e " \033[1;37mAccount ${_user}\033[m" + echo -e " Ausgewaehlt (Schluessel-Fehler im Report)..: ${_user_selected}" + if [[ $_user_prevalidation_failed -gt 0 ]] ; then + echo -e " Vor dem Bearbeiten aussortiert.............: \033[33m${_user_prevalidation_failed}\033[m" + fi + if [[ $_user_delete_failed -gt 0 ]] ; then + echo -e " Sicherung/Loeschen fehlgeschlagen..........: \033[1;31m${_user_delete_failed}\033[m" + fi + if $DRY_RUN ; then + echo -e " Wuerde geloescht & neu angelegt werden.....: \033[1;32m${_user_ok} (${_user_pct_ok} %)\033[m" + else + if [[ $_user_ok -gt 0 ]] ; then + echo -e " Geloescht, neu angelegt & verifiziert......: \033[1;32m${_user_ok} (${_user_pct_ok} %)\033[m" + else + echo -e " Geloescht, neu angelegt & verifiziert......: ${_user_ok} (${_user_pct_ok} %)" + fi + fi + if [[ $_user_shares_skipped -gt 0 ]] ; then + echo -e " Wegen aktiver Freigaben uebersprungen......: \033[33m${_user_shares_skipped}\033[m" + fi + if [[ $_user_written_unverified -gt 0 ]] ; then + echo -e " Geschrieben, aber ohne Login unverifiziert.: \033[33m${_user_written_unverified}\033[m (siehe Hinweis im Report - bitte manuell mit echtem Login pruefen)" + fi + if [[ $_user_write_errors -gt 0 ]] ; then + echo -e " Fehler beim Loeschen/Neuanlegen............: \033[1;31m${_user_write_errors}\033[m" + fi + echo "" + fi + + { + if [[ $_user_prevalidation_failed -gt 0 ]] ; then + echo "" + echo " ------------------------------------------------------------------" + echo " Vor dem Bearbeiten aussortiert - Account ${_user} (${_user_prevalidation_failed})" + echo " ------------------------------------------------------------------" + for _line in "${_user_prevalidation_failed_lines[@]}" ; do + printf ' %s\n' "$_line" + done + fi + if [[ $_user_delete_failed -gt 0 ]] ; then + echo "" + echo " ------------------------------------------------------------------" + echo " Sicherung/Loeschen fehlgeschlagen - Account ${_user} (${_user_delete_failed})" + echo " ------------------------------------------------------------------" + for _line in "${_user_delete_failed_lines[@]}" ; do + printf ' %s\n' "$_line" + done + fi + if [[ $_user_shares_skipped -gt 0 ]] ; then + echo "" + echo " ------------------------------------------------------------------" + echo " Wegen aktiver Freigaben uebersprungen - Account ${_user} (${_user_shares_skipped})" + echo " ------------------------------------------------------------------" + for _line in "${_user_shares_skipped_lines[@]}" ; do + printf ' %s\n' "$_line" + done + fi + if [[ $_user_written_unverified -gt 0 ]] ; then + echo "" + echo " ------------------------------------------------------------------" + echo " Geschrieben, aber ohne Login unverifiziert - Account ${_user} (${_user_written_unverified})" + echo " (dieses Skript hat keinen echten Benutzer-Login und kann fuer private," + echo " nicht oeffentlich freigegebene Dateien deshalb NICHT selbst pruefen, ob" + echo " die neue Datei fuer den echten Besitzer entschluesselbar ist - das ist" + echo " eine Einschraenkung dieser Verifikation, kein Hinweis auf einen Fehler." + echo " Bitte jede hier gelistete Datei einmal per echtem Browser-Login pruefen.)" + echo " ------------------------------------------------------------------" + for _line in "${_user_written_unverified_lines[@]}" ; do + printf ' %s\n' "$_line" + done + fi + if [[ $_user_write_errors -gt 0 ]] ; then + echo "" + echo " ------------------------------------------------------------------" + echo " Fehler beim Loeschen/Neuanlegen - Account ${_user} (${_user_write_errors})" + echo " ------------------------------------------------------------------" + for _line in "${_user_write_error_lines[@]}" ; do + printf ' %s\n' "$_line" + done + fi + } >> "$recreate_report_file" + + { + echo "" + echo " Account ${_user}" + echo " Ausgewaehlt (Schluessel-Fehler im Report)..: ${_user_selected}" + [[ $_user_prevalidation_failed -gt 0 ]] && echo " Vor dem Bearbeiten aussortiert.............: ${_user_prevalidation_failed}" + [[ $_user_delete_failed -gt 0 ]] && echo " Sicherung/Loeschen fehlgeschlagen..........: ${_user_delete_failed}" + if $DRY_RUN ; then + echo " Wuerde geloescht & neu angelegt werden.....: ${_user_ok} (${_user_pct_ok} %)" + else + echo " Geloescht, neu angelegt & verifiziert......: ${_user_ok} (${_user_pct_ok} %)" + fi + [[ $_user_shares_skipped -gt 0 ]] && echo " Wegen aktiver Freigaben uebersprungen......: ${_user_shares_skipped}" + [[ $_user_written_unverified -gt 0 ]] && echo " Geschrieben, aber ohne Login unverifiziert.: ${_user_written_unverified}" + [[ $_user_write_errors -gt 0 ]] && echo " Fehler beim Loeschen/Neuanlegen............: ${_user_write_errors}" + } >> "$recreate_report_file" + + (( total_selected += _user_selected )) + (( total_prevalidation_failed += _user_prevalidation_failed )) + (( total_delete_failed += _user_delete_failed )) + (( total_ok += _user_ok )) + (( total_shares_skipped += _user_shares_skipped )) + (( total_written_unverified += _user_written_unverified )) + (( total_write_errors += _user_write_errors )) + + unset _user_ok _user_shares_skipped _user_written_unverified _user_write_errors + unset _user_shares_skipped_lines _user_written_unverified_lines _user_write_error_lines + unset _user_prevalidation_failed _user_prevalidation_failed_lines + unset _user_delete_failed _user_delete_failed_lines + +done + + +total_pct_ok="$(calc_percent "$total_ok" "$total_selected")" + +{ + echo "" + echo "" + echo "==================================================================" + echo " Gesamtergebnis" + echo "==================================================================" + echo "" + echo "Ausgewaehlte Accounts.......................: ${#selected_user_arr[@]}" + [[ $total_failed_users -gt 0 ]] && echo "Accounts mit Recreate-Fehler................: ${total_failed_users}" + echo "Ausgewaehlt (Schluessel-Fehler im Report)...: ${total_selected}" + [[ $total_prevalidation_failed -gt 0 ]] && echo "Vor dem Bearbeiten aussortiert...............: ${total_prevalidation_failed}" + [[ $total_delete_failed -gt 0 ]] && echo "Sicherung/Loeschen fehlgeschlagen.............: ${total_delete_failed}" + if $DRY_RUN ; then + echo "Wuerde geloescht & neu angelegt werden.......: ${total_ok} (${total_pct_ok} %)" + else + echo "Geloescht, neu angelegt & verifiziert insgesamt: ${total_ok} (${total_pct_ok} %)" + fi + [[ $total_shares_skipped -gt 0 ]] && echo "Wegen aktiver Freigaben uebersprungen.........: ${total_shares_skipped}" + [[ $total_written_unverified -gt 0 ]] && echo "Geschrieben, aber ohne Login unverifiziert....: ${total_written_unverified}" + [[ $total_write_errors -gt 0 ]] && echo "Fehler beim Loeschen/Neuanlegen insgesamt.....: ${total_write_errors}" + echo "" + if $DRY_RUN ; then + echo "DRY-RUN: es wurde nichts geloescht, gesichert oder veraendert." + else + echo "Chiffretext- und Schluessel-Sicherung (vor dem Loeschen) liegt unter: ${recreate_backup_dir}" + echo "Bitte Ergebnisse trotz Verifikation stichprobenartig pruefen. Fuer uebersprungene/erfolgreiche Dateien mit vorheriger Freigabe muss die Freigabe manuell neu eingerichtet werden." + if [[ $total_written_unverified -gt 0 ]] ; then + echo "" + echo "WICHTIG zu 'Geschrieben, aber ohne Login unverifiziert': Dieses Skript hat keinen" + echo "echten Benutzer-Login und kann bei privaten (nicht oeffentlich freigegebenen)" + echo "Dateien deshalb grundsaetzlich NICHT selbst pruefen, ob die neu geschriebene" + echo "Datei fuer den echten Besitzer entschluesselbar ist (bekannte Einschraenkung von" + echo "KeyManager::getFileKey() im 'public access'-Modus). Das ist kein Hinweis auf" + echo "einen tatsaechlichen Fehler - bitte jede betroffene Datei einmal per echtem" + echo "Login/Browser pruefen." + fi + fi +} >> "$recreate_report_file" + +blank_line + +if $terminal ; then + echo -e "\033[37m\033[1mErgebnis\033[m" + echo "" + echo -e " Ausgewaehlte Accounts.......................: ${#selected_user_arr[@]}" + [[ $total_failed_users -gt 0 ]] && echo -e " Accounts mit Recreate-Fehler................: \033[1;31m${total_failed_users}\033[m" + echo -e " Ausgewaehlt (Schluessel-Fehler im Report)...: ${total_selected}" + [[ $total_prevalidation_failed -gt 0 ]] && echo -e " Vor dem Bearbeiten aussortiert...............: \033[33m${total_prevalidation_failed}\033[m" + [[ $total_delete_failed -gt 0 ]] && echo -e " Sicherung/Loeschen fehlgeschlagen.............: \033[1;31m${total_delete_failed}\033[m" + if $DRY_RUN ; then + echo -e " Wuerde geloescht & neu angelegt werden.......: \033[1;32m${total_ok} (${total_pct_ok} %)\033[m" + else + if [[ $total_ok -gt 0 ]] ; then + echo -e " Geloescht, neu angelegt & verifiziert insgesamt: \033[1;32m${total_ok} (${total_pct_ok} %)\033[m" + else + echo -e " Geloescht, neu angelegt & verifiziert insgesamt: ${total_ok} (${total_pct_ok} %)" + fi + fi + [[ $total_shares_skipped -gt 0 ]] && echo -e " Wegen aktiver Freigaben uebersprungen.........: \033[33m${total_shares_skipped}\033[m" + [[ $total_written_unverified -gt 0 ]] && echo -e " Geschrieben, aber ohne Login unverifiziert....: \033[33m${total_written_unverified}\033[m" + [[ $total_write_errors -gt 0 ]] && echo -e " Fehler beim Loeschen/Neuanlegen insgesamt.....: \033[1;31m${total_write_errors}\033[m" + echo "" + echo -e " Recreate-Report...............................: reports/$(basename "${recreate_report_file}")" + if ! $DRY_RUN ; then + echo -e " Chiffretext- und Schluessel-Sicherung.........: $recreate_backup_dir" + fi + echo "" + if $DRY_RUN ; then + info "Dry-run beendet - es wurde nichts veraendert. Ohne '-n' (oder mit [2] bei der Modusabfrage) fuer den echten Lauf erneut ausfuehren." + else + warn "Bitte die neu angelegten Dateien trotz Verifikation stichprobenartig im Webinterface pruefen. Fuer jede uebersprungene Datei mit aktiven Freigaben (siehe Report) muss die Freigabe danach manuell neu eingerichtet werden, falls die Datei per '-f' spaeter doch bearbeitet wird." + fi +fi + +clean_up 0 diff --git a/restore_bad_signature.sh b/restore_bad_signature.sh new file mode 100755 index 0000000..a60bbd2 --- /dev/null +++ b/restore_bad_signature.sh @@ -0,0 +1,1620 @@ +#!/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" + +# - Source of the files this script writes back into Nextcloud: the +# - already-recovered, already-validated copies produced by a PRIOR +# - recover_bad_signature.sh run (same default as that script - see the +# - comment there for why it lives outside script_dir/root). +# - +DEFAULT_RECOVERY_BASE_DIR="/var/nc-recovery" + +# - Before this script overwrites the still-broken original in +# - Nextcloud's own storage, it keeps a raw, byte-for-byte copy of the +# - CURRENT on-disk ciphertext (the file exactly as it is right now, +# - still failing signature checks) in a separate directory - a plain +# - filesystem copy, independent of Nextcloud/its encryption layer +# - entirely (which is the whole point: that layer can't "read" this +# - file cleanly right now anyway). This is a rollback path on top of +# - (not a replacement for) the already-recovered/validated copies under +# - DEFAULT_RECOVERY_BASE_DIR. +# - +DEFAULT_RESTORE_BACKUP_BASE_DIR="/var/nc-restore-backup" + +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) + + +# ============= +# --- Some functions +# ============= + +usage() { + + [[ -n "$1" ]] && error "$1" + + [[ $terminal ]] && echo -e " +\033[1mUsage:\033[m + + $(basename $0) -s + +\033[1mDescription\033[m + + Writes already-recovered, already-validated files (produced by a + PRIOR run of recover_bad_signature.sh, validation column = VALID in + its report) back into Nextcloud's own storage, at their ORIGINAL + path - through Nextcloud's normal Files API (the same write path a + regular upload/sync client write uses), so the Server-Side + Encryption layer transparently (re-)encrypts the content with a + fresh IV and a correct signature. Afterwards the file is a + completely normal, healthy encrypted file again: readable in the + web interface, over WebDAV, and by sync clients, with no more 'Bad + Signature' and no server-side flag left toggled. + + This is the ONLY script in this toolkit that writes into + Nextcloud's live storage - scan_bad_signature.sh and + recover_bad_signature.sh are both read-only towards Nextcloud + itself. Treat it accordingly. + + IMPORTANT: + - Only files whose recovery report marks them 'VALID' are ever + considered - 'Datenmuell'/INVALID and 'Nicht pruefbar'/ + UNVERIFIED entries are never written back, no override exists. + - Every file is re-validated with the CURRENT checks right + before writing (not just trusted from the old report), in case + this script's validation logic has improved since that report + was produced - files that no longer validate are skipped, not + forced through. + - Before each file is overwritten, the CURRENT on-disk ciphertext + (the still-broken original, exactly as it is right now) is + copied byte-for-byte to a separate backup directory (default + ${DEFAULT_RESTORE_BACKUP_BASE_DIR}/, override with + RESTORE_BACKUP_BASE_DIR in the conf file) - a plain filesystem + copy that bypasses Nextcloud/encryption entirely, so it works + even though the file cannot currently be read normally. This + only works for local/default storage; it is best-effort and a + missing source is reported but does not abort the run. + - This only reads the already-recovered copies under + ${DEFAULT_RECOVERY_BASE_DIR}/ (override with + RECOVERY_BASE_DIR) - it does NOT decrypt anything itself and + does NOT touch 'encryption_skip_signature_check'. Run + recover_bad_signature.sh first if that directory/report is + missing or stale. + - After writing, every file is immediately read back through the + NORMAL Nextcloud read path (no signature-check bypass) and + compared byte-for-byte (SHA-256) against the source - proof + that the file is genuinely healed, not just that a write + happened without error. + - If the 'files_versions' app is enabled, Nextcloud will also + keep its own snapshot of the overwritten (still-broken) + content as a file version automatically - an additional, + built-in safety net on top of the raw backup above. + + Unless '-n' is given on the command line, you will be asked + interactively whether to run a dry-run (nothing is written, just + reports what would happen) or the real thing. You will then be + asked which existing recovery report (produced by + recover_bad_signature.sh) to use, and then which account(s) from its + VALID entries to restore for: + + - a blank separated list of account names + - or 'all' to attempt every account found in the report + +\033[1mOptions\033[m + + -s + The site of the nextcloud instance. + + -n + Dry-run: goes through selection, re-validation and reporting, but + does NOT back up, overwrite or verify anything. 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 + + Dry-run only, nothing is written: + + $(basename $0) -n -s cloud-01.oopen.de +" + + clean_up 1 + +} + + +clean_up() { + + # Perform program exit housekeeping + [[ -n "$restore_php_file" ]] && rm -f "$restore_php_file" 2> /dev/null + rm -rf "$LOCK_DIR" + blank_line + exit $1 + +} + +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 file - IDENTICAL +## - to (and must be kept in sync with) validate_recovered_file() and +## - its helpers in recover_bad_signature.sh. Duplicated here on +## - purpose rather than sourced from that script: sourcing it would +## - also execute its top-level report-selection flow, not just define +## - functions. 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) + local _head + _head="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" + if [[ "$_head" = "89504e470d0a1a0a" ]] ; then + echo "VALID|PNG signature ok" + else + echo "INVALID|PNG signature missing" + fi + ;; + gif) + local _head6 + _head6="$(head -c6 "$_f" 2> /dev/null)" + if [[ "$_head6" = "GIF87a" || "$_head6" = "GIF89a" ]] ; then + echo "VALID|GIF signature ok" + else + echo "INVALID|GIF signature missing" + fi + ;; + bmp) + if [[ "$(head -c2 "$_f" 2> /dev/null)" = "BM" ]] ; then + echo "VALID|BMP signature ok" + else + echo "INVALID|BMP signature missing" + fi + ;; + tif|tiff) + 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" + else + echo "INVALID|TIFF signature missing" + 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 + if unzip -tq "$_f" > /dev/null 2>&1 ; then + echo "VALID|zip integrity ok" + else + local _badentry + _badentry="$(trim "$(unzip -t "$_f" 2>&1 | grep -v '^Archive:' | grep -v '^[[:space:]]*$' | head -1)")" + echo "INVALID|zip integrity check failed${_badentry:+ (${_badentry})}" + fi + else + echo "UNVERIFIED|unzip not installed" + fi + ;; + mp4|mov|m4v) + if head -c 64 "$_f" 2> /dev/null | grep -aq "ftyp" ; then + echo "VALID|mp4 ftyp box found" + else + echo "INVALID|mp4 ftyp box not found" + 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 '/ 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 + ;; + 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. +## - +_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 + if qpdf --check "$_f" > /dev/null 2>&1 ; then + _pdf_check_detail="qpdf --check ok" + return 0 + else + _pdf_check_detail="qpdf --check failed" + return 1 + fi + fi + _pdf_check_detail="PDF header ok (qpdf not installed, no deep check)" + return 0 +} + +## - JPEG structural check - see recover_bad_signature.sh for the full +## - rationale (a raw "ends in FFD9" byte check produces false +## - positives; prefer an actual decoder). 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) - see recover_bad_signature.sh for the full +## - rationale. +## - +_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:-}' extension), $_pdf_check_detail" + else + echo "UNVERIFIED|content-detected PDF ('.${_ext:-}' 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:-}') - re-run with that extension to structurally validate, or check manually" + return + ;; + esac + + 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 + + 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:-}', 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 (reads other users' files under +# - the Nextcloud data directory for the raw ciphertext backup, 'su' +# - into the webserver user to perform the actual restore writes). +# - +if [[ "$(id -u)" -ne 0 ]] ; then + fatal "This script must be run as root (it needs to read the Nextcloud data directory for backups and 'su' into the webserver user). 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 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 +# ------------- +DRY_RUN=false +_dry_run_explicit=false + +while getopts hns: opt ; do + case $opt in + h) usage ;; + n) DRY_RUN=true; _dry_run_explicit=true ;; + s) WEBSITE=$OPTARG ;; + \?) usage + esac +done + + +# ============= +# --- Ask which mode to run in, unless '-n' was already given on the +# --- command line (that still works unchanged for scripted/non- +# --- interactive invocations). Unlike recover_bad_signature.sh, the +# --- SAFE choice (dry-run) is the default here on purpose - this +# --- script writes into live Nextcloud storage. +# ============= +if ! $_dry_run_explicit && $terminal ; then + + blank_line + echo -e "\033[37m\033[1mWhich mode should this run use?\033[m" + echo "" + echo -e " \033[1m[1] Dry-run\033[m - go through everything (selection, re-validation, reporting), but write, back up and verify NOTHING" + echo " [2] Real restore - actually back up, overwrite and verify each file in Nextcloud" + info "Just press Return to use the default: [1] Dry-run." + echo -n " Select mode by number [1]: " + read _mode_choice + + case "$(trim "$_mode_choice")" in + 2) DRY_RUN=false ;; + ""|1) DRY_RUN=true ;; + *) 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)" + +# - Muss auf denselben Ort zeigen wie beim recover_bad_signature.sh-Lauf, +# - der die hier verwendeten Dateien erzeugt hat. +# - +[[ -n "$RECOVERY_BASE_DIR" ]] || RECOVERY_BASE_DIR=$DEFAULT_RECOVERY_BASE_DIR +recovery_dir="${RECOVERY_BASE_DIR}/${WEBSITE}" + +[[ -n "$RESTORE_BACKUP_BASE_DIR" ]] || RESTORE_BACKUP_BASE_DIR=$DEFAULT_RESTORE_BACKUP_BASE_DIR +backup_dir="${RESTORE_BACKUP_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 + +if $terminal ; then + echo "" + echo -e "\033[32m-----\033[m" + echo -e "Restore already-recovered \033[1mBad Signature\033[m files back into system \033[1m${WEB_BASE_DIR}\033[m" + echo -e "\033[1m + This WRITES into Nextcloud's live storage. Only files marked VALID + by a prior recover_bad_signature.sh run are ever restored, each is + re-validated again right before writing, the current (still broken) + original is backed up first, and every write is verified by reading + it back afterwards.\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 (informational - this script +# --- works either way, but the whole point of it assumes SSE is on) +# ============= + +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" + warn "Server-Side Encryption appears to be disabled on ${WEBSITE} right now. Restoring will still work (files are simply written unencrypted), but double-check this is expected." +fi + + +# ============= +# --- Determine available recovery reports (output of +# --- recover_bad_signature.sh - NOT the bad_signature_*.tsv scan +# --- reports) +# ============= + +mapfile -t _available_reports < <(ls -t "${report_dir}"/recovery_*.tsv 2> /dev/null) + +if [[ ${#_available_reports[@]} -eq 0 ]] ; then + fatal "No recovery report files (recovery_*.tsv) found in '${report_dir}'. Run recover_bad_signature.sh first." +fi + +blank_line +if $terminal ; then + echo -e "\033[37m\033[1mAvailable recovery 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))]}" + + +# ============= +# --- Extract "userpath" pairs for every VALID entry in the chosen +# --- report. The report has no per-row user column - accounts are +# --- section headers ('---------' / '' / '---------'). Only +# --- 6-tab-field data rows belong to the following case; the header +# --- row, the 'Datenmuell'/'Nicht pruefbar' detail blocks (2 fields) +# --- and the account-statistics lines (no tabs) never match NF==6. +# ============= + +valid_entries_file="${LOCK_DIR}/valid_entries.tsv" +awk -F'\t' ' + /^-+$/ { + if (state == 0) { state = 1 } + else if (state == 2) { state = 0 } + next + } + { + if (state == 1) { user = $0; state = 2; next } + if (NF == 6 && $1 != "path" && $5 == "VALID") { + print user "\t" $1 + } + } +' "$chosen_report" > "$valid_entries_file" + +unsorted_account_arr=($(cut -f1 "$valid_entries_file" | sort -u)) +IFS=$'\n' account_arr=($(sort <<<"${unsorted_account_arr[*]}")) +IFS=$CUR_IFS + +if [[ ${#account_arr[@]} -eq 0 ]] ; then + fatal "No 'VALID' entries found in '$(basename "$chosen_report")' - nothing to restore." +fi + +blank_line +if $terminal ; then + echo -e "\033[37m\033[1mAccounts with 'VALID' entries in this report\033[m" + echo "" + for _a in "${account_arr[@]}" ; do + _a_count=$(awk -F'\t' -v u="$_a" '$1==u' "$valid_entries_file" | wc -l) + echo " - $_a (${_a_count})" + done + echo "" +fi + +echo -n " Account(s) to restore VALID files 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 restore VALID files for (blank separated, or 'all'): " + read _input + +done + + +# ============= +# --- Confirmation +# ============= + +mkdir -p "$report_dir" 2> /dev/null +restore_report_file="${report_dir}/restore_${WEBSITE}_${run_date}.tsv" + +if $terminal ; then + echo "" + if $DRY_RUN ; then + echo -e "\033[1;32mStarting DRY-RUN restore for \033[1;37m${WEBSITE}\033[m" + else + echo -e "\033[1;31m\033[1mStarting REAL restore (writes into live Nextcloud storage) for \033[1;37m${WEBSITE}\033[m" + fi + echo "" + echo -e " Cloud instance........................: $WEBSITE" + echo "" + echo -e " Recovery report used...................: $(basename "$chosen_report")" + echo -e " Recovered files read from..............: $recovery_dir" + echo -e " Account(s) to restore..................: \033[33m${selected_user_arr[*]}\033[m" + echo "" + if $DRY_RUN ; then + echo -e " Nothing will be written - dry-run only." + else + echo -e " Ciphertext backup (before overwrite)...: $backup_dir" + echo -e " Files written back to their ORIGINAL path in Nextcloud." + fi + echo -e " Restore report..........................: reports/$(basename "${restore_report_file}")" + echo "" + + if $DRY_RUN ; then + info "Dry-run: selection and re-validation run for real, nothing is backed up, written or verified." + else + warn "This WRITES into Nextcloud's live storage, overwriting the still-broken original at its ORIGINAL path with the recovered content - the first script in this toolkit that does so. Only files re-validated as VALID right now are touched. The current (still broken) original is copied byte-for-byte to '${backup_dir}' first, and every write is read back and verified afterwards, but please still spot-check a few results yourself." + 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 restore VALID files for each selected account on \033[1;37m$WEBSITE \033[m" + else + fatal "Abort by user request - Answer as not 'YES'" + fi +fi + + +{ + echo "==================================================================" + echo " Bad-Signature-Restore-Versuch ${WEBSITE} ${run_date}" + echo "==================================================================" + echo "" + echo " Basis-Report (Recovery): $(basename "$chosen_report")" + echo " Nur Eintraege mit validation=VALID aus diesem Report werden zurueckgeschrieben." + $DRY_RUN && echo " DRY-RUN: es wurde NICHTS geschrieben, gesichert oder verifiziert." + ! $DRY_RUN && echo " Chiffretext-Sicherung (vor dem Ueberschreiben) liegt unter: ${backup_dir}" + echo "" + echo -e "path\tbytes_written\tstatus\tdetail" +} > "$restore_report_file" + + +# ============= +# --- Write the embedded PHP restore script +# ============= + +restore_php_file="${INSTALL_DIR}/.restore_bad_signature_$$.php" + +cat > "$restore_php_file" <<'PHP_RESTORE_EOF' + [--dry-run] + * listFile: TSV with "path\tlocalRecoveredFile" per line (no header) + */ + +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +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'; +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; + $dryRun = in_array('--dry-run', $argv, true); + + if ($uid === null || $listFile === null) { + fwrite(STDERR, "Usage: php restore_bad_signature.php [--dry-run]\n"); + exit(1); + } + if (!is_file($listFile)) { + fwrite(STDERR, "Liste nicht gefunden: $listFile\n"); + exit(1); + } + + $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\tbytes_written\tstatus\tdetail\n"; + + foreach ($lines as $line) { + $cols = explode("\t", $line); + $path = $cols[0] ?? ''; + $local = $cols[1] ?? ''; + if ($path === '' || $local === '') { + continue; + } + $total++; + + if (!is_file($local) || !is_readable($local)) { + $failed++; + echo "$path\t0\tERROR\tlocal recovered file not readable: $local\n"; + flush(); + continue; + } + + $localSize = filesize($local); + $localHash = hash_file('sha256', $local); + + if ($dryRun) { + $ok++; + echo "$path\t$localSize\tDRY_RUN\twould write $localSize bytes and verify readback (sha256 $localHash)\n"; + flush(); + continue; + } + + try { + $node = null; + try { + $node = $rootFolder->get($path); + } catch (\Throwable $e) { + $node = null; + } + + if ($node !== null && !($node instanceof \OCP\Files\File)) { + $failed++; + echo "$path\t0\tERROR\ttarget exists but is not a regular file\n"; + flush(); + continue; + } + + if ($node === null) { + // Original missing entirely (unexpected - the file this + // was recovered from should still be there, just + // unreadable). Create it fresh at the same path rather + // than silently skipping it. + $userFolder = $rootFolder->getUserFolder($uid); + $relative = preg_replace('#^/' . preg_quote($uid, '#') . '/files/#', '', $path); + if ($relative === null || $relative === '') { + $relative = ltrim($path, '/'); + } + $node = $userFolder->newFile($relative); + } + + $in = fopen($local, 'r'); + if ($in === false) { + throw new \RuntimeException("lokale Datei konnte nicht geoeffnet werden: $local"); + } + $node->putContent($in); + if (is_resource($in)) { + fclose($in); + } + + // Verify: re-read through the SAME normal API path (no + // signature-check bypass) and compare byte-for-byte. + clearstatcache(); + $freshNode = $rootFolder->get($path); + $readBack = $freshNode->fopen('r'); + if ($readBack === false) { + $failed++; + echo "$path\t0\tWRITE_ERROR\tputContent() did not throw, but re-read (fopen) failed\n"; + flush(); + continue; + } + + $ctx = hash_init('sha256'); + $bytesRead = 0; + while (!feof($readBack)) { + $chunk = fread($readBack, 1048576); + if ($chunk === false) { + break; + } + $bytesRead += strlen($chunk); + hash_update($ctx, $chunk); + } + fclose($readBack); + $readBackHash = hash_final($ctx); + + if ($readBackHash === $localHash && $bytesRead === $localSize) { + $ok++; + echo "$path\t$bytesRead\tOK\tverified: re-read $bytesRead bytes, sha256 matches source ($readBackHash)\n"; + } else { + $failed++; + $hashNote = ($readBackHash === $localHash) ? 'sha256 matches' : 'sha256 MISMATCH'; + echo "$path\t$bytesRead\tVERIFY_MISMATCH\tread back $bytesRead bytes (expected $localSize), $hashNote\n"; + } + flush(); + } catch (\Throwable $e) { + $failed++; + $msg = str_replace(["\t", "\n", "\r"], ' ', $e->getMessage()); + echo "$path\t0\tWRITE_ERROR\t" . get_class($e) . ": $msg\n"; + flush(); + } + } + + fwrite(STDERR, "\nFertig. Dateien verarbeitet: $total, ok: $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_RESTORE_EOF + +chmod 644 "$restore_php_file" + + +# ----- +# - 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 [[ ! -d "$recovery_dir" ]] ; then + fatal "Recovery directory '$recovery_dir' does not exist - run recover_bad_signature.sh first." +fi + +if ! $DRY_RUN ; then + mkdir -p "$backup_dir" 2> /dev/null +fi + +declare -i total_selected=0 +declare -i total_prevalidation_failed=0 +declare -i total_ok=0 +declare -i total_write_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" + } >> "$restore_report_file" + + user_recovery_dir="${recovery_dir}/${_user}" + + declare -i _user_selected=0 + declare -i _user_prevalidation_failed=0 + declare -a _user_prevalidation_failed_lines=() + + list_file="${LOCK_DIR}/list_${_user}.tsv" + > "$list_file" + + # ============= + # --- Re-validate every VALID-marked entry against the CURRENT + # --- checks right before writing anything, and (unless dry-run) + # --- back up the current on-disk ciphertext byte-for-byte first. + # ============= + while IFS=$'\t' read -r _u _path ; do + [[ "$_u" != "$_user" ]] && continue + (( _user_selected++ )) + + _rel="${_path#/${_user}/files/}" + _local="${user_recovery_dir}/${_rel}" + + if [[ ! -f "$_local" ]] ; then + (( _user_prevalidation_failed++ )) + _user_prevalidation_failed_lines+=("${_path}"$'\t'"recovered file missing under ${user_recovery_dir} - re-run recover_bad_signature.sh") + continue + fi + + _val_result="$(validate_recovered_file "$_local" "$_path" "")" + _val_class="${_val_result%%|*}" + _val_detail="${_val_result#*|}" + + if [[ "$_val_class" != "VALID" ]] ; then + (( _user_prevalidation_failed++ )) + _user_prevalidation_failed_lines+=("${_path}"$'\t'"no longer validates as VALID (${_val_class}: ${_val_detail}) - skipped, not restored") + continue + fi + + if ! $DRY_RUN ; then + _orig_ciphertext="${DATA_DIR}/${_user}/files/${_rel}" + if [[ -f "$_orig_ciphertext" ]] ; then + _backup_target="${backup_dir}/${_user}/${_rel}" + mkdir -p "$(dirname "$_backup_target")" 2> /dev/null + cp -p "$_orig_ciphertext" "$_backup_target" 2> /dev/null + fi + fi + + printf '%s\t%s\n' "$_path" "$_local" >> "$list_file" + + done < "$valid_entries_file" + + if [[ $_user_selected -eq 0 ]] ; then + warn "No 'VALID' entries for account '${_user}' found in the selected report - skipping." + echo " [ keine VALID-Eintraege im gewaehlten Report ]" >> "$restore_report_file" + continue + fi + + user_result_tsv="${LOCK_DIR}/result_${_user}.tsv" + + if $DRY_RUN ; then + echononl " Dry-run for account \033[1;37m${_user}\033[m (${_user_selected} selected, $(wc -l < "$list_file") to check).." + else + echononl " Restoring account \033[1;37m${_user}\033[m (${_user_selected} selected, $(wc -l < "$list_file") to write).." + fi + + if [[ -s "$list_file" ]] ; then + if $DRY_RUN ; then + su -c "$PHP_BIN $restore_php_file $_user $list_file --dry-run" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file" + else + su -c "$PHP_BIN $restore_php_file $_user $list_file" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file" + fi + _rc=$? + else + echo -e "path\tbytes_written\tstatus\tdetail" > "$user_result_tsv" + _rc=0 + fi + + if [[ $_rc -ne 0 ]]; then + echo_failed + error "$(cat "$log_file")" + echo " [ FEHLER beim Restore-Lauf - siehe Server-Log ]" >> "$restore_report_file" + (( total_failed_users++ )) + continue + fi + + echo_done + $terminal && echo "" + + declare -i _user_ok=0 + declare -i _user_write_errors=0 + declare -a _user_write_error_lines=() + + while IFS=$'\t' read -r _path _bytes _status _detail ; do + + [[ "$_path" = "path" ]] && continue + [[ -z "$_path" ]] && continue + + case "$_status" in + OK|DRY_RUN) (( _user_ok++ )) ;; + *) (( _user_write_errors++ )); _user_write_error_lines+=("${_path}"$'\t'"${_status}: ${_detail}") ;; + esac + + # printf, not 'echo -e': $_detail 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/\n in the format string itself. + printf '%s\t%s\t%s\t%s\n' "$_path" "$_bytes" "$_status" "$_detail" >> "$restore_report_file" + + done < "$user_result_tsv" + + _user_pct_ok="$(calc_percent "$_user_ok" "$_user_selected")" + + if $terminal ; then + echo -e " \033[1;37mAccount ${_user}\033[m" + echo -e " Ausgewaehlt (validation=VALID im Report)..: ${_user_selected}" + if [[ $_user_prevalidation_failed -gt 0 ]] ; then + echo -e " Vor dem Schreiben aussortiert..............: \033[33m${_user_prevalidation_failed}\033[m" + fi + if $DRY_RUN ; then + echo -e " Wuerde geschrieben & verifiziert werden....: \033[1;32m${_user_ok} (${_user_pct_ok} %)\033[m" + else + if [[ $_user_ok -gt 0 ]] ; then + echo -e " Zurueckgeschrieben & verifiziert...........: \033[1;32m${_user_ok} (${_user_pct_ok} %)\033[m" + else + echo -e " Zurueckgeschrieben & verifiziert...........: ${_user_ok} (${_user_pct_ok} %)" + fi + fi + if [[ $_user_write_errors -gt 0 ]] ; then + echo -e " Fehler beim Schreiben/Verifizieren.........: \033[1;31m${_user_write_errors}\033[m" + fi + echo "" + fi + + # - Problem files listed as their own, visually set-off block(s), + # - before the account statistics - same convention as + # - recover_bad_signature.sh. + # - + { + if [[ $_user_prevalidation_failed -gt 0 ]] ; then + echo "" + echo " ------------------------------------------------------------------" + echo " Vor dem Schreiben aussortiert - Account ${_user} (${_user_prevalidation_failed})" + echo " ------------------------------------------------------------------" + for _line in "${_user_prevalidation_failed_lines[@]}" ; do + printf ' %s\n' "$_line" + done + fi + if [[ $_user_write_errors -gt 0 ]] ; then + echo "" + echo " ------------------------------------------------------------------" + echo " Fehler beim Schreiben/Verifizieren - Account ${_user} (${_user_write_errors})" + echo " ------------------------------------------------------------------" + for _line in "${_user_write_error_lines[@]}" ; do + printf ' %s\n' "$_line" + done + fi + } >> "$restore_report_file" + + { + echo "" + echo " Account ${_user}" + echo " Ausgewaehlt (validation=VALID im Report)..: ${_user_selected}" + [[ $_user_prevalidation_failed -gt 0 ]] && echo " Vor dem Schreiben aussortiert..............: ${_user_prevalidation_failed}" + if $DRY_RUN ; then + echo " Wuerde geschrieben & verifiziert werden....: ${_user_ok} (${_user_pct_ok} %)" + else + echo " Zurueckgeschrieben & verifiziert...........: ${_user_ok} (${_user_pct_ok} %)" + fi + [[ $_user_write_errors -gt 0 ]] && echo " Fehler beim Schreiben/Verifizieren.........: ${_user_write_errors}" + } >> "$restore_report_file" + + (( total_selected += _user_selected )) + (( total_prevalidation_failed += _user_prevalidation_failed )) + (( total_ok += _user_ok )) + (( total_write_errors += _user_write_errors )) + + unset _user_valid _user_prevalidation_failed _user_prevalidation_failed_lines + unset _user_ok _user_write_errors _user_write_error_lines + +done + + +total_pct_ok="$(calc_percent "$total_ok" "$total_selected")" + +{ + echo "" + echo "" + echo "==================================================================" + echo " Gesamtergebnis" + echo "==================================================================" + echo "" + echo "Ausgewaehlte Accounts.......................: ${#selected_user_arr[@]}" + [[ $total_failed_users -gt 0 ]] && echo "Accounts mit Restore-Fehler.................: ${total_failed_users}" + echo "Ausgewaehlt (validation=VALID im Report)....: ${total_selected}" + [[ $total_prevalidation_failed -gt 0 ]] && echo "Vor dem Schreiben aussortiert...............: ${total_prevalidation_failed}" + if $DRY_RUN ; then + echo "Wuerde geschrieben & verifiziert werden.....: ${total_ok} (${total_pct_ok} %)" + else + echo "Zurueckgeschrieben & verifiziert insgesamt...: ${total_ok} (${total_pct_ok} %)" + fi + [[ $total_write_errors -gt 0 ]] && echo "Fehler beim Schreiben/Verifizieren insgesamt: ${total_write_errors}" + echo "" + if $DRY_RUN ; then + echo "DRY-RUN: es wurde nichts geschrieben, gesichert oder veraendert." + else + echo "Chiffretext-Sicherung (vor dem Ueberschreiben) liegt unter: ${backup_dir}" + echo "Bitte Ergebnisse trotz Verifikation stichprobenartig pruefen." + fi +} >> "$restore_report_file" + +blank_line + +if $terminal ; then + echo -e "\033[37m\033[1mErgebnis\033[m" + echo "" + echo -e " Ausgewaehlte Accounts.......................: ${#selected_user_arr[@]}" + [[ $total_failed_users -gt 0 ]] && echo -e " Accounts mit Restore-Fehler.................: \033[1;31m${total_failed_users}\033[m" + echo -e " Ausgewaehlt (validation=VALID im Report)....: ${total_selected}" + [[ $total_prevalidation_failed -gt 0 ]] && echo -e " Vor dem Schreiben aussortiert...............: \033[33m${total_prevalidation_failed}\033[m" + if $DRY_RUN ; then + echo -e " Wuerde geschrieben & verifiziert werden.....: \033[1;32m${total_ok} (${total_pct_ok} %)\033[m" + else + if [[ $total_ok -gt 0 ]] ; then + echo -e " Zurueckgeschrieben & verifiziert insgesamt...: \033[1;32m${total_ok} (${total_pct_ok} %)\033[m" + else + echo -e " Zurueckgeschrieben & verifiziert insgesamt...: ${total_ok} (${total_pct_ok} %)" + fi + fi + [[ $total_write_errors -gt 0 ]] && echo -e " Fehler beim Schreiben/Verifizieren insgesamt: \033[1;31m${total_write_errors}\033[m" + echo "" + echo -e " Restore-Report...............................: reports/$(basename "${restore_report_file}")" + if ! $DRY_RUN ; then + echo -e " Chiffretext-Sicherung........................: $backup_dir" + fi + echo "" + if $DRY_RUN ; then + info "Dry-run beendet - es wurde nichts geschrieben. Ohne '-n' (oder mit [2] bei der Modusabfrage) fuer den echten Restore erneut ausfuehren." + else + warn "Bitte die zurueckgeschriebenen Dateien trotz Verifikation stichprobenartig im Webinterface pruefen." + fi +fi + +clean_up 0