Steuerkram/scripts/analyse_nextcloud.sh

333 lines
13 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.
#
# 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'
<?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;
}
// ── 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