333 lines
13 KiB
Bash
Executable file
333 lines
13 KiB
Bash
Executable file
#!/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
|