Merge pull request 'Analyse-Skript fuer die Nextcloud-Verbindung' (#163) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/163
This commit is contained in:
commit
548ad55ef4
4 changed files with 419 additions and 0 deletions
|
|
@ -2,6 +2,14 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-08-01 – Analyse-Skript für die Nextcloud-Verbindung
|
||||||
|
|
||||||
|
- **`scripts/analyse_nextcloud.sh`** (neu): Grenzt Störungen des Belegimports ein, ohne dass dafür Einzeiler auf dem Server nötig sind. Das Skript liest die hinterlegte Konfiguration (Passwort nur als Länge und Verschlüsselungsstatus, nie im Klartext), zeigt Namensauflösung und IP-Pinning des SSRF-Schutzes, unternimmt einen PROPFIND auf den Import-Ordner, listet dessen Inhalt inklusive der Dateien, die wegen ihres Typs übersprungen würden, und prüft die Erreichbarkeit des Erledigte-Ordners.
|
||||||
|
- Schlägt der Verbindungsversuch fehl, folgen automatisch Gegenproben – ohne IP-Pinning sowie je Adressfamilie einzeln – und ein Befund, der zwischen abgewiesener Anmeldung (401/403), fehlendem Ordner (404), einer nicht nutzbaren Adressfamilie und einem generell nicht erreichbaren Server unterscheidet. Die cURL-Optionen entsprechen bewusst denen von `NextcloudImportService::newCurl()`, damit die Analyse den echten Importlauf abbildet.
|
||||||
|
- Rein lesend: keine schreibenden WebDAV-Methoden, kein Importlauf – bei der Fehlersuche werden also keine Belege verschoben oder gelöscht. Exit-Code 0 bedeutet, dass die Verbindung steht. Läuft das Skript im Container, ruft es `php` direkt auf, sonst über `docker compose exec`.
|
||||||
|
- **`tests/75_AnalyseNextcloudSkriptTest.php`** (neu): Sichert bash-Syntax, Ausführbarkeit und vor allem zu, dass das Skript lesend bleibt und das Passwort nicht ausgibt.
|
||||||
|
- **`docs/konfiguration.md`:** Abschnitt „Verbindung analysieren" unter „Nextcloud-Import einrichten".
|
||||||
|
|
||||||
## 2026-08-01 – Nextcloud-Belegimport wieder funktionsfähig (IP-Pinning bei Dual-Stack-Hosts)
|
## 2026-08-01 – Nextcloud-Belegimport wieder funktionsfähig (IP-Pinning bei Dual-Stack-Hosts)
|
||||||
|
|
||||||
- Seit dem Deployment des SSRF-Schutzes (#130) schlug der Nextcloud-Import bei jedem Lauf mit „PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen." fehl. Ursache war nicht die Konfiguration, sondern das IP-Pinning: `SsrfGuard::resolvePinned()` gab pro aufgelöster IP-Adresse einen eigenen `CURLOPT_RESOLVE`-Eintrag zurück. libcurl behandelt mehrere Einträge für dieselbe `HOST:PORT`-Kombination aber nicht als Alternativen – der zuletzt gesetzte verdrängt die vorherigen. Bei einem Host mit A- **und** AAAA-Record blieb dadurch nur die IPv6-Adresse übrig, und da der Container keine IPv6-Konnektivität hat, scheiterte jede Verbindung sofort mit „Could not connect to server".
|
- Seit dem Deployment des SSRF-Schutzes (#130) schlug der Nextcloud-Import bei jedem Lauf mit „PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen." fehl. Ursache war nicht die Konfiguration, sondern das IP-Pinning: `SsrfGuard::resolvePinned()` gab pro aufgelöster IP-Adresse einen eigenen `CURLOPT_RESOLVE`-Eintrag zurück. libcurl behandelt mehrere Einträge für dieselbe `HOST:PORT`-Kombination aber nicht als Alternativen – der zuletzt gesetzte verdrängt die vorherigen. Bei einem Host mit A- **und** AAAA-Record blieb dadurch nur die IPv6-Adresse übrig, und da der Container keine IPv6-Konnektivität hat, scheiterte jede Verbindung sofort mit „Could not connect to server".
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,17 @@ sudo usermod -aG docker $USER
|
||||||
# Danach neu einloggen
|
# Danach neu einloggen
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Verbindung analysieren
|
||||||
|
|
||||||
|
Meldet der Import „PROPFIND fehlgeschlagen" oder findet er keine Dateien, grenzt `scripts/analyse_nextcloud.sh` die Ursache ein. Das Skript wird auf dem Host aufgerufen (nicht im Container) und arbeitet rein lesend – es importiert, verschiebt und löscht nichts. Das Passwort wird nie im Klartext ausgegeben.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/analyse_nextcloud.sh # Konfiguration, DNS, Verbindungsversuch
|
||||||
|
./scripts/analyse_nextcloud.sh --verbose # zusätzlich ein Auszug der Server-Antwort
|
||||||
|
```
|
||||||
|
|
||||||
|
Ausgegeben werden die hinterlegte Konfiguration, die Namensauflösung samt IP-Pinning des SSRF-Schutzes, ein PROPFIND auf den Import-Ordner, der Inhalt des Ordners (inkl. Dateien, die wegen ihres Typs übersprungen würden) und die Erreichbarkeit des Erledigte-Ordners. Schlägt der Verbindungsversuch fehl, folgen Gegenproben ohne IP-Pinning sowie je Adressfamilie einzeln – daran ist ablesbar, ob Zugangsdaten, Ordnerpfad oder die Netzwerkverbindung (z.B. fehlende IPv6-Konnektivität) die Ursache sind. Exit-Code 0 bedeutet, dass die Verbindung steht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Benutzer-Passwort zurücksetzen
|
## Benutzer-Passwort zurücksetzen
|
||||||
|
|
|
||||||
333
scripts/analyse_nextcloud.sh
Executable file
333
scripts/analyse_nextcloud.sh
Executable file
|
|
@ -0,0 +1,333 @@
|
||||||
|
#!/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
|
||||||
67
tests/75_AnalyseNextcloudSkriptTest.php
Normal file
67
tests/75_AnalyseNextcloudSkriptTest.php
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 75: Analyse-Skript für die Nextcloud-Verbindung (scripts/analyse_nextcloud.sh).
|
||||||
|
*
|
||||||
|
* Das Skript ist ein Diagnosewerkzeug für den Produktivbetrieb. Es muss
|
||||||
|
* ausführbar und syntaktisch gültig sein und darf ausschließlich lesen –
|
||||||
|
* ein versehentlich ergänzter MOVE/DELETE oder ein Aufruf des Importlaufs
|
||||||
|
* würde bei der Fehlersuche Belege verschieben oder löschen.
|
||||||
|
*/
|
||||||
|
class AnalyseNextcloudSkriptTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 75: Analyse-Skript Nextcloud-Verbindung';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$path = dirname(__DIR__) . '/scripts/analyse_nextcloud.sh';
|
||||||
|
|
||||||
|
$this->assert(file_exists($path), 'scripts/analyse_nextcloud.sh existiert');
|
||||||
|
$this->assert(is_executable($path), 'scripts/analyse_nextcloud.sh ist ausführbar');
|
||||||
|
|
||||||
|
exec('bash -n ' . escapeshellarg($path) . ' 2>&1', $output, $exitCode);
|
||||||
|
$this->assertEquals(0, $exitCode, 'bash-Syntax ist gültig (' . implode(' ', $output) . ')');
|
||||||
|
|
||||||
|
$content = (string)file_get_contents($path);
|
||||||
|
|
||||||
|
// Rein lesend: keine schreibenden WebDAV-Methoden, kein Importlauf.
|
||||||
|
foreach (['MOVE', 'DELETE', 'PUT', 'MKCOL'] as $method) {
|
||||||
|
$this->assert(
|
||||||
|
!str_contains($content, "'" . $method . "'") && !str_contains($content, '"' . $method . '"'),
|
||||||
|
'Skript verwendet keine schreibende WebDAV-Methode ' . $method
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verweise auf NextcloudImportService im Kommentar sind erwünscht (die cURL-Optionen
|
||||||
|
// müssen dort synchron gehalten werden) – ausgeführt werden darf der Import nicht.
|
||||||
|
$this->assert(
|
||||||
|
!str_contains($content, 'nextcloud-import.php') && !str_contains($content, 'new NextcloudImportService'),
|
||||||
|
'Skript startet keinen Importlauf'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Das Passwort dient ausschließlich als cURL-Zugangsdatum. In Ausgaben darf
|
||||||
|
// es nur als Länge (strlen) auftauchen, nie im Klartext.
|
||||||
|
foreach (explode("\n", $content) as $index => $line) {
|
||||||
|
if (!str_contains($line, '$pass') || !preg_match('/\b(echo|print|printf|sprintf|print_r|var_export)\b/', $line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->assert(
|
||||||
|
!str_contains(str_replace('strlen($pass)', '', $line), '$pass'),
|
||||||
|
'Passwort erscheint in Ausgabe-Zeile ' . ($index + 1) . ' höchstens als Länge'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Analyse muss den echten SSRF-/Pinning-Pfad verwenden, sonst prüft sie
|
||||||
|
// etwas anderes als der Importlauf.
|
||||||
|
$this->assert(
|
||||||
|
str_contains($content, 'SsrfGuard::resolvePinned'),
|
||||||
|
'Skript prüft das IP-Pinning über SsrfGuard::resolvePinned()'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
str_contains($content, 'PROPFIND'),
|
||||||
|
'Skript unternimmt einen PROPFIND-Verbindungsversuch'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue