[ "configured" => !empty($types["s3"]), "available" => function_exists("hash_hmac"), ], "sftp" => [ "configured" => !empty($types["sftp"]), "available" => function_exists("ssh2_connect") && function_exists("ssh2_sftp"), ], "custom" => [ "configured" => !empty($types["custom"]), "available" => true, ], ]; } function manageRemoteUploadToS3(string $archivePath, array $metadata, array $target): array { $bucket = trim((string) ($target["bucket"] ?? "")); $region = trim((string) ($target["region"] ?? "")); $accessKey = trim((string) ($target["access_key"] ?? "")); $secretKey = (string) ($target["secret_key"] ?? ""); $prefix = trim((string) ($target["prefix"] ?? ""), "/"); $endpoint = rtrim(trim((string) ($target["endpoint"] ?? "")), "/"); if ($bucket === "" || $region === "" || $accessKey === "" || $secretKey === "") { throw new RuntimeException("S3-Ziel ist unvollständig konfiguriert."); } $filename = basename($archivePath); $key = ($prefix !== "" ? $prefix . "/" : "") . $filename; $host = $endpoint !== "" ? parse_url($endpoint, PHP_URL_HOST) : $bucket . ".s3." . $region . ".amazonaws.com"; if (!is_string($host) || $host === "") { throw new RuntimeException("S3-Endpunkt ist ungültig."); } $url = $endpoint !== "" ? $endpoint . "/" . rawurlencode($bucket) . "/" . str_replace("%2F", "/", rawurlencode($key)) : "https://" . $host . "/" . str_replace("%2F", "/", rawurlencode($key)); // The payload must be hashed as a whole for SigV4, so a backup larger than // memory_limit cannot use this target. $payload = file_get_contents($archivePath); if ($payload === false) { throw new RuntimeException("Backup-ZIP konnte für S3 nicht gelesen werden."); } $now = gmdate("Ymd\THis\Z"); $date = substr($now, 0, 8); $payloadHash = hash("sha256", $payload); $canonicalUri = parse_url($url, PHP_URL_PATH); $canonicalUri = is_string($canonicalUri) && $canonicalUri !== "" ? $canonicalUri : "/"; $signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date"; $canonicalHeaders = "content-type:application/zip\n" . "host:" . $host . "\n" . "x-amz-content-sha256:" . $payloadHash . "\n" . "x-amz-date:" . $now . "\n"; $canonicalRequest = "PUT\n" . $canonicalUri . "\n\n" . $canonicalHeaders . "\n" . $signedHeaders . "\n" . $payloadHash; $scope = $date . "/" . $region . "/s3/aws4_request"; $stringToSign = "AWS4-HMAC-SHA256\n" . $now . "\n" . $scope . "\n" . hash("sha256", $canonicalRequest); $kDate = hash_hmac("sha256", $date, "AWS4" . $secretKey, true); $kRegion = hash_hmac("sha256", $region, $kDate, true); $kService = hash_hmac("sha256", "s3", $kRegion, true); $kSigning = hash_hmac("sha256", "aws4_request", $kService, true); $signature = hash_hmac("sha256", $stringToSign, $kSigning); $authorization = "AWS4-HMAC-SHA256 Credential=" . $accessKey . "/" . $scope . ", SignedHeaders=" . $signedHeaders . ", Signature=" . $signature; $context = stream_context_create([ "http" => [ "method" => "PUT", "timeout" => (int) ($target["timeout"] ?? 120), "ignore_errors" => true, "follow_location" => 0, "header" => "Content-Type: application/zip\r\n" . "Content-Length: " . strlen($payload) . "\r\n" . "Host: " . $host . "\r\n" . "X-Amz-Date: " . $now . "\r\n" . "X-Amz-Content-Sha256: " . $payloadHash . "\r\n" . "Authorization: " . $authorization . "\r\n" . "User-Agent: " . manageClientUserAgent() . "\r\n", "content" => $payload, ], ]); $response = @file_get_contents($url, false, $context); $phpError = $response === false ? manageRemoteLastPhpError() : ""; $headers = manageClientResponseHeaders($http_response_header ?? null); $status = manageClientStatusFromHeaders($headers); if ($response === false || $status < 200 || $status >= 300) { throw new ManageRemoteUploadException( "S3-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".", [ "http_status" => $status, "response_excerpt" => manageRemoteResponseExcerpt($response), "php_error" => $phpError, "bucket" => $bucket, "region" => $region, "key" => $key, "endpoint" => $endpoint, ], ); } return ["remote_path" => "s3://" . $bucket . "/" . $key]; } function manageRemoteUploadToSftp(string $archivePath, array $metadata, array $target): array { if (!function_exists("ssh2_connect") || !function_exists("ssh2_sftp")) { throw new RuntimeException("Die PHP-SSH2-Erweiterung ist nicht verfügbar."); } $host = trim((string) ($target["host"] ?? "")); $username = trim((string) ($target["username"] ?? "")); $password = (string) ($target["password"] ?? ""); $remoteDir = rtrim((string) ($target["path"] ?? ""), "/"); $port = (int) ($target["port"] ?? 22); if ($host === "" || $username === "" || $remoteDir === "") { throw new RuntimeException("SFTP-Ziel ist unvollständig konfiguriert."); } $connection = @ssh2_connect($host, $port > 0 ? $port : 22); if ($connection === false) { throw new RuntimeException("SFTP-Verbindung konnte nicht hergestellt werden."); } $authenticated = false; $privateKey = trim((string) ($target["private_key"] ?? "")); $publicKey = trim((string) ($target["public_key"] ?? "")); if ($privateKey !== "" && $publicKey !== "" && function_exists("ssh2_auth_pubkey_file")) { $authenticated = @ssh2_auth_pubkey_file( $connection, $username, $publicKey, $privateKey, $password !== "" ? $password : null, ); } elseif (function_exists("ssh2_auth_password")) { $authenticated = @ssh2_auth_password($connection, $username, $password); } if (!$authenticated) { throw new RuntimeException("SFTP-Anmeldung fehlgeschlagen."); } $sftp = @ssh2_sftp($connection); if ($sftp === false) { throw new RuntimeException("SFTP-Subsystem konnte nicht gestartet werden."); } $remotePath = $remoteDir . "/" . basename($archivePath); $targetStream = @fopen("ssh2.sftp://" . intval($sftp) . $remotePath, "wb"); if ($targetStream === false) { throw new RuntimeException("SFTP-Zieldatei konnte nicht geöffnet werden. Existiert das Verzeichnis?"); } $source = fopen($archivePath, "rb"); if ($source === false) { fclose($targetStream); throw new RuntimeException("Backup-ZIP konnte für SFTP nicht gelesen werden."); } $copied = stream_copy_to_stream($source, $targetStream); fclose($source); fclose($targetStream); if ($copied === false) { throw new RuntimeException("SFTP-Upload fehlgeschlagen."); } return ["remote_path" => "sftp://" . $host . $remotePath]; } function manageRemoteUploadToCustom(string $archivePath, array $metadata, array $target): array { $file = trim((string) ($target["file"] ?? "")); $callback = $target["callback"] ?? null; if ($file !== "") { if (!is_file($file)) { throw new RuntimeException("Custom-Uploader-Datei wurde nicht gefunden: " . $file); } require_once $file; } if (!is_callable($callback)) { throw new RuntimeException("Custom-Uploader ist nicht aufrufbar."); } $result = call_user_func($callback, $archivePath, $metadata, $target); if ($result === true) { return []; } if (is_array($result) && ($result["success"] ?? true) !== false) { return $result; } if (is_array($result)) { throw new RuntimeException(trim((string) ($result["error"] ?? "Custom-Uploader meldet einen Fehler."))); } throw new RuntimeException("Custom-Uploader meldet einen Fehler."); } /** * Runs every configured extra target. Each is attempted independently and a * failure never invalidates the local backup: the error is recorded in the * backup index and logged. */ function manageRemoteUploadAll(string $archivePath, array $metadata): array { $results = []; foreach (manageRemoteTargets() as $index => $target) { if (!is_array($target)) { continue; } $type = trim((string) ($target["type"] ?? "")); $label = manageRemoteTargetLabel($target, (int) $index); $startedAt = date(DATE_ATOM); try { if ($type === "s3") { $extra = manageRemoteUploadToS3($archivePath, $metadata, $target); } elseif ($type === "sftp") { $extra = manageRemoteUploadToSftp($archivePath, $metadata, $target); } elseif ($type === "custom") { $extra = manageRemoteUploadToCustom($archivePath, $metadata, $target); } else { throw new RuntimeException("Unbekannter Backup-Zieltyp: " . ($type !== "" ? $type : "(leer)")); } $results[] = array_merge([ "target" => $label, "type" => $type, "success" => true, "started_at" => $startedAt, "uploaded_at" => date(DATE_ATOM), ], $extra); manageClientLog("INFO", "Remote upload succeeded", [ "target" => $label, "type" => $type, "filename" => $metadata["filename"] ?? basename($archivePath), ]); } catch (Throwable $exception) { $debugContext = $exception instanceof ManageRemoteUploadException ? $exception->getDebugContext() : []; $result = [ "target" => $label, "type" => $type !== "" ? $type : "unknown", "success" => false, "started_at" => $startedAt, "error" => $exception->getMessage(), ]; if ($debugContext !== []) { $result["debug"] = $debugContext; } $results[] = $result; manageClientLog("ERROR", "Remote upload failed", [ "target" => $label, "type" => $type !== "" ? $type : "unknown", "target_config" => manageRemoteSafeContext($target), "filename" => $metadata["filename"] ?? basename($archivePath), "error" => $exception->getMessage(), "debug" => $debugContext, ]); } } return $results; }