#!/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. # # 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, 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' $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 = '' . '' . '' . ''; $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; } // ── 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; } 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; } } echo PHP_EOL . ' ' . $green('Die Verbindung zur Nextcloud funktioniert.') . PHP_EOL . PHP_EOL; exit(0); PHPEOF