Steuerkram/scripts/analyse-nextcloud.sh
fds2610-bot b7508fe401 Fix #209: analyse-nextcloud.sh prüft, ob das Importskript vorhanden und ladbar ist
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 17:33:07 +02:00

380 lines
15 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# ════════════════════════════════════════════════════════════════════════════
# Steuerkram – Analyse der Nextcloud-Verbindung
#
# Liest die in den Einstellungen hinterlegte Nextcloud-Konfiguration aus und
# unternimmt einen echten Verbindungsversuch (PROPFIND auf den Import-Ordner).
# Schlägt der fehl, grenzt das Script die Ursache über Gegenproben ein:
# ohne IP-Pinning, nur IPv4, nur IPv6.
#
# Zuletzt wird geprüft, ob das Importskript im Container existiert und ladbar
# ist (der Cronjob muss denselben Pfad verwenden).
#
# Rein lesend – es werden keine Belege importiert, verschoben oder gelöscht.
# Das Passwort wird nie im Klartext ausgegeben.
#
# Usage:
# ./analyse-nextcloud.sh [--verbose]
#
# Exit-Code: 0 = Verbindung steht und Importskript vorhanden, 1 = Konfigurations- oder Verbindungsfehler
# ════════════════════════════════════════════════════════════════════════════
set -euo pipefail
VERBOSE=0
for arg in "$@"; do
case "$arg" in
--verbose|-v) VERBOSE=1 ;;
--help|-h)
echo "Usage: $0 [--verbose]"
exit 0
;;
*)
echo "Unbekannte Option: $arg" >&2
echo "Usage: $0 [--verbose]" >&2
exit 1
;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
COLOR=0
[[ -t 1 ]] && COLOR=1
# Läuft das Script bereits im Container, wird php direkt aufgerufen –
# sonst über den web-Container.
run_php() {
if [[ -f /.dockerenv ]]; then
ANALYSE_VERBOSE="$VERBOSE" ANALYSE_COLOR="$COLOR" php
else
if ! docker compose ps --status running --services 2>/dev/null | grep -qx web; then
echo "Der Container 'web' läuft nicht. Bitte zuerst: docker compose up -d" >&2
exit 1
fi
docker compose exec -T -e ANALYSE_VERBOSE="$VERBOSE" -e ANALYSE_COLOR="$COLOR" web php
fi
}
run_php <<'PHPEOF'
<?php
declare(strict_types=1);
chdir('/var/www/html');
require 'vendor/autoload.php';
use Steuerkram\Config;
use Steuerkram\Database;
use Steuerkram\Models\Setting;
use Steuerkram\Services\Crypto;
use Steuerkram\Services\SsrfGuard;
Config::load();
$verbose = getenv('ANALYSE_VERBOSE') === '1';
$color = getenv('ANALYSE_COLOR') === '1';
$paint = static fn(string $code, string $text): string => $color ? "\033[{$code}m{$text}\033[0m" : $text;
$green = static fn(string $t): string => $paint('0;32', $t);
$red = static fn(string $t): string => $paint('0;31', $t);
$amber = static fn(string $t): string => $paint('1;33', $t);
$bold = static fn(string $t): string => $paint('1', $t);
$section = static function (string $title) use ($bold): void {
echo PHP_EOL . $bold('── ' . $title . ' ') . str_repeat('─', max(0, 60 - mb_strlen($title))) . PHP_EOL;
};
/**
* Führt einen PROPFIND aus. Die cURL-Optionen entsprechen bewusst
* NextcloudImportService::newCurl() – nur so bildet die Analyse den echten
* Importlauf ab. Änderungen dort müssen hier nachgezogen werden.
*
* @param string[] $resolve CURLOPT_RESOLVE-Einträge (leer = cURL löst selbst auf)
* @return array{code:int, errno:int, error:string, ip:string, time:float, body:string}
*/
function propfind(string $url, string $user, string $pass, array $resolve, int $depth = 1): array
{
$body = '<?xml version="1.0" encoding="UTF-8"?>'
. '<d:propfind xmlns:d="DAV:">'
. '<d:prop><d:displayname/><d:getcontenttype/><d:getcontentlength/><d:resourcetype/></d:prop>'
. '</d:propfind>';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PROPFIND',
CURLOPT_POSTFIELDS => $body,
CURLOPT_USERPWD => $user . ':' . $pass,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_TIMEOUT => 20,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/xml; charset=utf-8',
'Depth: ' . $depth,
],
]);
if ($resolve !== []) {
curl_setopt($ch, CURLOPT_RESOLVE, $resolve);
}
$response = curl_exec($ch);
$result = [
'code' => (int)curl_getinfo($ch, CURLINFO_HTTP_CODE),
'errno' => curl_errno($ch),
'error' => curl_error($ch),
'ip' => (string)curl_getinfo($ch, CURLINFO_PRIMARY_IP),
'time' => (float)curl_getinfo($ch, CURLINFO_TOTAL_TIME),
'body' => $response === false ? '' : (string)$response,
];
curl_close($ch);
return $result;
}
/** Kurzform eines Ergebnisses für die Ausgabe. */
function describe(array $r): string
{
if ($r['errno'] !== 0) {
return sprintf('cURL-Fehler %d: %s', $r['errno'], $r['error']);
}
return sprintf('HTTP %d von %s (%.2fs)', $r['code'], $r['ip'] !== '' ? $r['ip'] : 'unbekannt', $r['time']);
}
function isOk(array $r): bool
{
return $r['errno'] === 0 && $r['code'] >= 200 && $r['code'] < 300;
}
/**
* Prüft, ob der Importlauf selbst startbar ist. Die Analyse läuft über die
* Standardeingabe und braucht das Importskript nicht – ein verschobenes oder
* fehlendes Skript bliebe sonst unbemerkt, obwohl der Cronjob ins Leere läuft.
* Das Skript wird nur auf Syntax geprüft (php -l), nicht ausgeführt.
*/
$checkImportScript = static function () use ($section, $green, $red, $bold): bool {
$section('6. Importskript (Cronjob-Ziel)');
$rel = 'scripts/container/nextcloud-import.php';
$path = '/var/www/html/' . $rel;
if (!is_file($path)) {
echo ' ' . $red('✗') . ' ' . $rel . ' fehlt im Container.' . PHP_EOL;
echo ' Der Cronjob findet das Skript nicht und der Import läuft nie.' . PHP_EOL;
echo ' Auch prüfen, ob der Bind-Mount ./scripts:/var/www/html/scripts den Image-Inhalt verdeckt.' . PHP_EOL;
return false;
}
echo ' ' . $green('✓') . ' ' . $rel . ' vorhanden' . PHP_EOL;
$output = [];
exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($path) . ' 2>&1', $output, $code);
if ($code !== 0) {
echo ' ' . $red('✗') . ' Das Skript ist nicht ladbar: ' . implode(' ', $output) . PHP_EOL;
return false;
}
echo ' ' . $green('✓') . ' Syntax gültig' . PHP_EOL;
echo PHP_EOL . ' ' . $bold('Hinweis:') . ' Der Host-Cronjob muss genau diesen Pfad verwenden:' . PHP_EOL;
echo ' docker compose exec -T web php ' . $rel . PHP_EOL;
echo ' Mit `crontab -l` auf dem Host abgleichen.' . PHP_EOL;
return true;
};
// ── 1. Konfiguration ────────────────────────────────────────────────────────
$section('1. Konfiguration (Einstellungen → Nextcloud-Belegimport)');
try {
$settings = Setting::get();
} catch (\RuntimeException $e) {
echo ' ' . $red('✗') . ' Einstellungen nicht lesbar: ' . $e->getMessage() . PHP_EOL;
echo ' Die Passwörter in den Einstellungen sind mit APP_KEY verschlüsselt.' . PHP_EOL;
echo ' Prüfen: APP_KEY in der .env muss unverändert dem Wert entsprechen,' . PHP_EOL;
echo ' mit dem das Passwort gespeichert wurde (64-stellige Hex-Zeichenkette).' . PHP_EOL;
exit(1);
}
$url = rtrim((string)($settings['nextcloud_url'] ?? ''), '/');
$user = (string)($settings['nextcloud_user'] ?? '');
$pass = (string)($settings['nextcloud_pass'] ?? '');
$folder = '/' . trim((string)($settings['nextcloud_folder'] ?: 'Belege/Import'), '/');
$doneRaw = (string)($settings['nextcloud_done'] ?? '');
$done = $doneRaw !== '' ? '/' . trim($doneRaw, '/') : null;
$rawPass = (string)(Database::fetchOne('SELECT nextcloud_pass FROM company_settings WHERE id = 1')['nextcloud_pass'] ?? '');
printf(" URL: %s\n", $url !== '' ? $url : $red('(leer)'));
printf(" Benutzer: %s\n", $user !== '' ? $user : $red('(leer)'));
printf(" Import-Ordner: %s\n", $folder);
printf(" Erledigt: %s\n", $done ?? $amber('(nicht gesetzt – Dateien werden nach dem Import gelöscht)'));
printf(
" Passwort: %s\n",
$pass !== ''
? sprintf('gesetzt, %d Zeichen, in der Datenbank %s', strlen($pass), Crypto::isEncrypted($rawPass) ? 'verschlüsselt' : $amber('im Klartext'))
: $red('(leer)')
);
if ($url === '' || $user === '' || $pass === '') {
echo PHP_EOL . ' ' . $red('✗') . ' Unvollständige Konfiguration – der Import kann so nicht laufen.' . PHP_EOL;
exit(1);
}
// ── 2. Namensauflösung und IP-Pinning ───────────────────────────────────────
$section('2. Namensauflösung und IP-Pinning (SSRF-Schutz)');
$host = (string)parse_url($url, PHP_URL_HOST);
$port = (int)(parse_url($url, PHP_URL_PORT) ?? 443);
foreach ((array)@dns_get_record($host, DNS_A + DNS_AAAA) as $record) {
printf(" DNS %-4s %s\n", $record['type'] ?? '?', $record['ip'] ?? $record['ipv6'] ?? '-');
}
try {
$pinned = SsrfGuard::resolvePinned($url);
} catch (\RuntimeException $e) {
echo ' ' . $red('✗') . ' URL abgelehnt: ' . $e->getMessage() . PHP_EOL;
echo ' Zulässig sind ausschließlich https-URLs zu öffentlich erreichbaren Adressen.' . PHP_EOL;
exit(1);
}
$addresses = [];
if ($pinned !== []) {
printf(" Pinning: %s\n", implode(' | ', $pinned));
$prefix = $host . ':' . $port . ':';
$addresses = explode(',', substr($pinned[0], strlen($prefix)));
} else {
echo ' Pinning: entfällt (die URL enthält bereits eine IP-Adresse)' . PHP_EOL;
}
// ── 3. Verbindungsversuch ───────────────────────────────────────────────────
$section('3. Verbindungsversuch (PROPFIND auf den Import-Ordner)');
$davBase = $url . '/remote.php/dav/files/' . rawurlencode($user);
$folderUrl = $davBase . $folder;
echo ' ' . $folderUrl . PHP_EOL;
$result = propfind($folderUrl, $user, $pass, $pinned);
if (isOk($result)) {
echo ' ' . $green('✓') . ' ' . describe($result) . PHP_EOL;
} else {
echo ' ' . $red('✗') . ' ' . describe($result) . PHP_EOL;
}
if ($verbose && $result['body'] !== '') {
echo PHP_EOL . ' Antwort (erste 800 Zeichen):' . PHP_EOL;
echo ' ' . str_replace("\n", "\n ", substr($result['body'], 0, 800)) . PHP_EOL;
}
// ── 4. Eingrenzung bei Fehlschlag ───────────────────────────────────────────
if (!isOk($result)) {
$section('4. Eingrenzung');
$probes = ['ohne IP-Pinning' => []];
foreach ($addresses as $address) {
$family = str_starts_with($address, '[') ? 'nur IPv6' : 'nur IPv4';
$probes[$family . ' (' . trim($address, '[]') . ')'] = [$host . ':' . $port . ':' . $address];
}
$probeResults = [];
foreach ($probes as $name => $resolve) {
$probe = propfind($folderUrl, $user, $pass, $resolve);
$probeResults[$name] = $probe;
printf(" %-34s %s %s\n", $name, isOk($probe) ? $green('✓') : $red('✗'), describe($probe));
}
echo PHP_EOL;
$anyProbeOk = array_filter($probeResults, static fn(array $r): bool => isOk($r)) !== [];
$authRejected = $result['errno'] === 0 && in_array($result['code'], [401, 403], true);
if ($authRejected) {
echo ' ' . $bold('Befund:') . ' Der Server ist erreichbar, weist die Anmeldung aber ab (HTTP '
. $result['code'] . ').' . PHP_EOL;
echo ' Benutzername und App-Passwort in den Einstellungen prüfen – ein in Nextcloud' . PHP_EOL;
echo ' widerrufenes App-Passwort erzeugt genau dieses Bild.' . PHP_EOL;
} elseif ($result['errno'] === 0 && $result['code'] === 404) {
echo ' ' . $bold('Befund:') . ' Anmeldung erfolgreich, aber der Ordner ' . $folder
. ' existiert nicht.' . PHP_EOL;
echo ' Pfad in den Einstellungen prüfen (Groß-/Kleinschreibung beachten).' . PHP_EOL;
} elseif ($anyProbeOk) {
echo ' ' . $bold('Befund:') . ' Der Server ist erreichbar – nur der Weg dorthin scheitert.' . PHP_EOL;
echo ' Eine der Gegenproben oben war erfolgreich; deren Adressfamilie funktioniert,' . PHP_EOL;
echo ' die des eigentlichen Versuchs nicht. Typisch, wenn der Host einen AAAA-Record' . PHP_EOL;
echo ' hat, der Container aber keine IPv6-Verbindung ins Internet hat.' . PHP_EOL;
} else {
echo ' ' . $bold('Befund:') . ' Der Server war über keinen Weg erreichbar.' . PHP_EOL;
echo ' Netzwerkverbindung, Firewall und TLS-Zertifikat der Nextcloud prüfen.' . PHP_EOL;
}
$checkImportScript();
exit(1);
}
// ── 5. Inhalt des Import-Ordners ────────────────────────────────────────────
$section('4. Inhalt des Import-Ordners');
$dom = new DOMDocument();
$files = [];
if (@$dom->loadXML($result['body'])) {
$xpath = new DOMXPath($dom);
$xpath->registerNamespace('d', 'DAV:');
foreach ($xpath->query('//d:response') ?: [] as $response) {
$collection = $xpath->query('.//d:resourcetype/d:collection', $response);
if ($collection !== false && $collection->length > 0) {
continue;
}
$href = $xpath->query('.//d:href', $response);
$type = $xpath->query('.//d:getcontenttype', $response);
if ($href === false || $href->length === 0) {
continue;
}
$files[] = [
'name' => rawurldecode(basename($href->item(0)->textContent)),
'type' => $type !== false && $type->length > 0 ? trim(explode(';', $type->item(0)->textContent)[0]) : '',
];
}
}
$importable = ['application/pdf', 'image/png', 'image/jpeg', 'image/tiff', 'application/xml', 'text/xml'];
printf(" %d Datei(en) im Ordner\n", count($files));
foreach ($files as $file) {
$ok = in_array($file['type'], $importable, true);
printf(
" %s %-45s %s\n",
$ok ? $green('✓') : $amber('–'),
mb_strimwidth($file['name'], 0, 45, '…'),
$ok ? $file['type'] : $file['type'] . ' (wird übersprungen)'
);
}
// ── 6. Erledigt-Ordner ──────────────────────────────────────────────────────
if ($done !== null) {
$section('5. Erledigt-Ordner');
$doneUrl = $davBase . $done;
$doneResult = propfind($doneUrl, $user, $pass, $pinned, 0);
if (isOk($doneResult)) {
echo ' ' . $green('✓') . ' ' . $done . ' vorhanden' . PHP_EOL;
} else {
echo ' ' . $red('✗') . ' ' . $done . ' nicht erreichbar: ' . describe($doneResult) . PHP_EOL;
echo ' Importierte Dateien lassen sich dann nicht verschieben und bleiben liegen.' . PHP_EOL;
}
}
$importOk = $checkImportScript();
if (!$importOk) {
echo PHP_EOL . ' ' . $red('Die Verbindung steht, aber der Importlauf ist nicht startbar.') . PHP_EOL . PHP_EOL;
exit(1);
}
echo PHP_EOL . ' ' . $green('Die Verbindung zur Nextcloud funktioniert.') . PHP_EOL . PHP_EOL;
exit(0);
PHPEOF