2002 lines
79 KiB
Bash
Executable File
2002 lines
79 KiB
Bash
Executable File
#!/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 <website>
|
|
|
|
\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}/<website>,
|
|
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}/<website>
|
|
(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 <website>
|
|
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 '<html|<!doctype html' ; then
|
|
echo "VALID|HTML tag found near start"
|
|
else
|
|
echo "INVALID|no <html>/<!DOCTYPE html> 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:-<none>}' extension), $_pdf_check_detail"
|
|
else
|
|
echo "UNVERIFIED|content-detected PDF ('.${_ext:-<none>}' extension), but $_pdf_check_detail"
|
|
fi
|
|
return
|
|
;;
|
|
image/jpeg|image/png|image/gif|image/bmp|image/tiff|application/zip|video/mp4|video/quicktime)
|
|
echo "UNVERIFIED|content-detected as $_magic (extension was '.${_ext:-<none>}') - re-run with that extension to structurally validate, or check manually"
|
|
return
|
|
;;
|
|
esac
|
|
|
|
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:-<none>}', detected type: ${_magic:-file/libmagic not installed}, looks like text: ${_looks_like_text}, ${_size_note}"
|
|
}
|
|
|
|
blank_line() {
|
|
if $terminal ; then
|
|
echo ""
|
|
fi
|
|
}
|
|
|
|
|
|
|
|
# - Running in a terminal?
|
|
# -
|
|
if [[ -t 1 ]] ; then
|
|
terminal=true
|
|
else
|
|
terminal=false
|
|
fi
|
|
|
|
# - This script needs root privileges (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 "user<TAB>path" 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'
|
|
<?php
|
|
/**
|
|
* recreate_bad_signature.php
|
|
*
|
|
* For files where a normal overwrite fails because Nextcloud cannot
|
|
* decrypt/reuse the EXISTING file key (MultiKeyDecryptException, or
|
|
* the generic 'probably this is a shared file' message) - this
|
|
* DELETES the existing (broken) node first and creates a brand-new
|
|
* file at the same path, so Nextcloud never has to decrypt any old
|
|
* key material; a fresh symmetric key is generated and freshly
|
|
* wrapped for the current access list.
|
|
*
|
|
* SAFETY: before deleting anything, this checks (read-only) whether
|
|
* the file currently has any active Nextcloud shares. If it does, the
|
|
* file is SKIPPED (status SHARES_FOUND_SKIPPED) unless --force is
|
|
* given - deleting a shared file's node breaks every existing share.
|
|
*
|
|
* Usage: php recreate_bad_signature.php <uid> <listFile> [--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 <uid> <listFile> <dataDir> [--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
|