remote.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. <?php
  2. declare(strict_types=1);
  3. // Extra backup destinations besides the manage server. Ported from the PSA
  4. // order system (includes/backup.php): s3, sftp and custom.
  5. //
  6. // The old "managed" target type is gone: uploading to the manage server is now
  7. // built in (manageBackupUpload) and configured through MANAGE_SERVER_URL /
  8. // MANAGE_INSTANCE / MANAGE_TOKEN instead of a target entry.
  9. function manageRemoteTargets(): array
  10. {
  11. return is_array(MANAGE_BACKUP_REMOTE_TARGETS) ? MANAGE_BACKUP_REMOTE_TARGETS : [];
  12. }
  13. function manageRemoteTargetLabel(array $target, int $index): string
  14. {
  15. $name = trim((string) ($target["name"] ?? ""));
  16. if ($name !== "") {
  17. return $name;
  18. }
  19. $type = trim((string) ($target["type"] ?? "target"));
  20. return $type . "-" . ($index + 1);
  21. }
  22. // Whitelist of non-secret keys, so a failure can be logged with useful context
  23. // without ever writing an access key or password to disk.
  24. function manageRemoteSafeContext(array $target): array
  25. {
  26. $safe = [];
  27. $allowedKeys = [
  28. "name", "type", "url", "bucket", "region", "prefix", "endpoint",
  29. "host", "port", "username", "path", "file", "callback", "timeout",
  30. ];
  31. foreach ($allowedKeys as $key) {
  32. if (array_key_exists($key, $target)) {
  33. $safe[$key] = is_scalar($target[$key]) ? (string) $target[$key] : gettype($target[$key]);
  34. }
  35. }
  36. return $safe;
  37. }
  38. function manageRemoteResponseExcerpt($response): string
  39. {
  40. if (!is_string($response) || $response === "") {
  41. return "";
  42. }
  43. $response = preg_replace('/\s+/', " ", trim($response));
  44. return is_string($response) ? substr($response, 0, 500) : "";
  45. }
  46. function manageRemoteLastPhpError(): string
  47. {
  48. $error = error_get_last();
  49. if (!is_array($error)) {
  50. return "";
  51. }
  52. return substr(trim((string) ($error["message"] ?? "")), 0, 500);
  53. }
  54. // Reports which target types this installation can actually use, so the GUI can
  55. // warn about a configured target that will always fail.
  56. function manageRemoteCapabilities(): array
  57. {
  58. $types = [];
  59. foreach (manageRemoteTargets() as $target) {
  60. if (is_array($target)) {
  61. $type = trim((string) ($target["type"] ?? ""));
  62. if ($type !== "") {
  63. $types[$type] = true;
  64. }
  65. }
  66. }
  67. return [
  68. "s3" => [
  69. "configured" => !empty($types["s3"]),
  70. "available" => function_exists("hash_hmac"),
  71. ],
  72. "sftp" => [
  73. "configured" => !empty($types["sftp"]),
  74. "available" => function_exists("ssh2_connect") && function_exists("ssh2_sftp"),
  75. ],
  76. "custom" => [
  77. "configured" => !empty($types["custom"]),
  78. "available" => true,
  79. ],
  80. ];
  81. }
  82. function manageRemoteUploadToS3(string $archivePath, array $metadata, array $target): array
  83. {
  84. $bucket = trim((string) ($target["bucket"] ?? ""));
  85. $region = trim((string) ($target["region"] ?? ""));
  86. $accessKey = trim((string) ($target["access_key"] ?? ""));
  87. $secretKey = (string) ($target["secret_key"] ?? "");
  88. $prefix = trim((string) ($target["prefix"] ?? ""), "/");
  89. $endpoint = rtrim(trim((string) ($target["endpoint"] ?? "")), "/");
  90. if ($bucket === "" || $region === "" || $accessKey === "" || $secretKey === "") {
  91. throw new RuntimeException("S3-Ziel ist unvollständig konfiguriert.");
  92. }
  93. $filename = basename($archivePath);
  94. $key = ($prefix !== "" ? $prefix . "/" : "") . $filename;
  95. $host = $endpoint !== ""
  96. ? parse_url($endpoint, PHP_URL_HOST)
  97. : $bucket . ".s3." . $region . ".amazonaws.com";
  98. if (!is_string($host) || $host === "") {
  99. throw new RuntimeException("S3-Endpunkt ist ungültig.");
  100. }
  101. $url = $endpoint !== ""
  102. ? $endpoint . "/" . rawurlencode($bucket) . "/" . str_replace("%2F", "/", rawurlencode($key))
  103. : "https://" . $host . "/" . str_replace("%2F", "/", rawurlencode($key));
  104. // The payload must be hashed as a whole for SigV4, so a backup larger than
  105. // memory_limit cannot use this target.
  106. $payload = file_get_contents($archivePath);
  107. if ($payload === false) {
  108. throw new RuntimeException("Backup-ZIP konnte für S3 nicht gelesen werden.");
  109. }
  110. $now = gmdate("Ymd\THis\Z");
  111. $date = substr($now, 0, 8);
  112. $payloadHash = hash("sha256", $payload);
  113. $canonicalUri = parse_url($url, PHP_URL_PATH);
  114. $canonicalUri = is_string($canonicalUri) && $canonicalUri !== "" ? $canonicalUri : "/";
  115. $signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
  116. $canonicalHeaders =
  117. "content-type:application/zip\n" .
  118. "host:" . $host . "\n" .
  119. "x-amz-content-sha256:" . $payloadHash . "\n" .
  120. "x-amz-date:" . $now . "\n";
  121. $canonicalRequest =
  122. "PUT\n" . $canonicalUri . "\n\n" . $canonicalHeaders . "\n" . $signedHeaders . "\n" . $payloadHash;
  123. $scope = $date . "/" . $region . "/s3/aws4_request";
  124. $stringToSign =
  125. "AWS4-HMAC-SHA256\n" . $now . "\n" . $scope . "\n" . hash("sha256", $canonicalRequest);
  126. $kDate = hash_hmac("sha256", $date, "AWS4" . $secretKey, true);
  127. $kRegion = hash_hmac("sha256", $region, $kDate, true);
  128. $kService = hash_hmac("sha256", "s3", $kRegion, true);
  129. $kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
  130. $signature = hash_hmac("sha256", $stringToSign, $kSigning);
  131. $authorization =
  132. "AWS4-HMAC-SHA256 Credential=" . $accessKey . "/" . $scope .
  133. ", SignedHeaders=" . $signedHeaders . ", Signature=" . $signature;
  134. $context = stream_context_create([
  135. "http" => [
  136. "method" => "PUT",
  137. "timeout" => (int) ($target["timeout"] ?? 120),
  138. "ignore_errors" => true,
  139. "follow_location" => 0,
  140. "header" =>
  141. "Content-Type: application/zip\r\n" .
  142. "Content-Length: " . strlen($payload) . "\r\n" .
  143. "Host: " . $host . "\r\n" .
  144. "X-Amz-Date: " . $now . "\r\n" .
  145. "X-Amz-Content-Sha256: " . $payloadHash . "\r\n" .
  146. "Authorization: " . $authorization . "\r\n" .
  147. "User-Agent: " . manageClientUserAgent() . "\r\n",
  148. "content" => $payload,
  149. ],
  150. ]);
  151. $response = @file_get_contents($url, false, $context);
  152. $phpError = $response === false ? manageRemoteLastPhpError() : "";
  153. $headers = manageClientResponseHeaders($http_response_header ?? null);
  154. $status = manageClientStatusFromHeaders($headers);
  155. if ($response === false || $status < 200 || $status >= 300) {
  156. throw new ManageRemoteUploadException(
  157. "S3-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
  158. [
  159. "http_status" => $status,
  160. "response_excerpt" => manageRemoteResponseExcerpt($response),
  161. "php_error" => $phpError,
  162. "bucket" => $bucket,
  163. "region" => $region,
  164. "key" => $key,
  165. "endpoint" => $endpoint,
  166. ],
  167. );
  168. }
  169. return ["remote_path" => "s3://" . $bucket . "/" . $key];
  170. }
  171. function manageRemoteUploadToSftp(string $archivePath, array $metadata, array $target): array
  172. {
  173. if (!function_exists("ssh2_connect") || !function_exists("ssh2_sftp")) {
  174. throw new RuntimeException("Die PHP-SSH2-Erweiterung ist nicht verfügbar.");
  175. }
  176. $host = trim((string) ($target["host"] ?? ""));
  177. $username = trim((string) ($target["username"] ?? ""));
  178. $password = (string) ($target["password"] ?? "");
  179. $remoteDir = rtrim((string) ($target["path"] ?? ""), "/");
  180. $port = (int) ($target["port"] ?? 22);
  181. if ($host === "" || $username === "" || $remoteDir === "") {
  182. throw new RuntimeException("SFTP-Ziel ist unvollständig konfiguriert.");
  183. }
  184. $connection = @ssh2_connect($host, $port > 0 ? $port : 22);
  185. if ($connection === false) {
  186. throw new RuntimeException("SFTP-Verbindung konnte nicht hergestellt werden.");
  187. }
  188. $authenticated = false;
  189. $privateKey = trim((string) ($target["private_key"] ?? ""));
  190. $publicKey = trim((string) ($target["public_key"] ?? ""));
  191. if ($privateKey !== "" && $publicKey !== "" && function_exists("ssh2_auth_pubkey_file")) {
  192. $authenticated = @ssh2_auth_pubkey_file(
  193. $connection,
  194. $username,
  195. $publicKey,
  196. $privateKey,
  197. $password !== "" ? $password : null,
  198. );
  199. } elseif (function_exists("ssh2_auth_password")) {
  200. $authenticated = @ssh2_auth_password($connection, $username, $password);
  201. }
  202. if (!$authenticated) {
  203. throw new RuntimeException("SFTP-Anmeldung fehlgeschlagen.");
  204. }
  205. $sftp = @ssh2_sftp($connection);
  206. if ($sftp === false) {
  207. throw new RuntimeException("SFTP-Subsystem konnte nicht gestartet werden.");
  208. }
  209. $remotePath = $remoteDir . "/" . basename($archivePath);
  210. $targetStream = @fopen("ssh2.sftp://" . intval($sftp) . $remotePath, "wb");
  211. if ($targetStream === false) {
  212. throw new RuntimeException("SFTP-Zieldatei konnte nicht geöffnet werden. Existiert das Verzeichnis?");
  213. }
  214. $source = fopen($archivePath, "rb");
  215. if ($source === false) {
  216. fclose($targetStream);
  217. throw new RuntimeException("Backup-ZIP konnte für SFTP nicht gelesen werden.");
  218. }
  219. $copied = stream_copy_to_stream($source, $targetStream);
  220. fclose($source);
  221. fclose($targetStream);
  222. if ($copied === false) {
  223. throw new RuntimeException("SFTP-Upload fehlgeschlagen.");
  224. }
  225. return ["remote_path" => "sftp://" . $host . $remotePath];
  226. }
  227. function manageRemoteUploadToCustom(string $archivePath, array $metadata, array $target): array
  228. {
  229. $file = trim((string) ($target["file"] ?? ""));
  230. $callback = $target["callback"] ?? null;
  231. if ($file !== "") {
  232. if (!is_file($file)) {
  233. throw new RuntimeException("Custom-Uploader-Datei wurde nicht gefunden: " . $file);
  234. }
  235. require_once $file;
  236. }
  237. if (!is_callable($callback)) {
  238. throw new RuntimeException("Custom-Uploader ist nicht aufrufbar.");
  239. }
  240. $result = call_user_func($callback, $archivePath, $metadata, $target);
  241. if ($result === true) {
  242. return [];
  243. }
  244. if (is_array($result) && ($result["success"] ?? true) !== false) {
  245. return $result;
  246. }
  247. if (is_array($result)) {
  248. throw new RuntimeException(trim((string) ($result["error"] ?? "Custom-Uploader meldet einen Fehler.")));
  249. }
  250. throw new RuntimeException("Custom-Uploader meldet einen Fehler.");
  251. }
  252. /**
  253. * Runs every configured extra target. Each is attempted independently and a
  254. * failure never invalidates the local backup: the error is recorded in the
  255. * backup index and logged.
  256. */
  257. function manageRemoteUploadAll(string $archivePath, array $metadata): array
  258. {
  259. $results = [];
  260. foreach (manageRemoteTargets() as $index => $target) {
  261. if (!is_array($target)) {
  262. continue;
  263. }
  264. $type = trim((string) ($target["type"] ?? ""));
  265. $label = manageRemoteTargetLabel($target, (int) $index);
  266. $startedAt = date(DATE_ATOM);
  267. try {
  268. if ($type === "s3") {
  269. $extra = manageRemoteUploadToS3($archivePath, $metadata, $target);
  270. } elseif ($type === "sftp") {
  271. $extra = manageRemoteUploadToSftp($archivePath, $metadata, $target);
  272. } elseif ($type === "custom") {
  273. $extra = manageRemoteUploadToCustom($archivePath, $metadata, $target);
  274. } else {
  275. throw new RuntimeException("Unbekannter Backup-Zieltyp: " . ($type !== "" ? $type : "(leer)"));
  276. }
  277. $results[] = array_merge([
  278. "target" => $label,
  279. "type" => $type,
  280. "success" => true,
  281. "started_at" => $startedAt,
  282. "uploaded_at" => date(DATE_ATOM),
  283. ], $extra);
  284. manageClientLog("INFO", "Remote upload succeeded", [
  285. "target" => $label,
  286. "type" => $type,
  287. "filename" => $metadata["filename"] ?? basename($archivePath),
  288. ]);
  289. } catch (Throwable $exception) {
  290. $debugContext = $exception instanceof ManageRemoteUploadException
  291. ? $exception->getDebugContext()
  292. : [];
  293. $result = [
  294. "target" => $label,
  295. "type" => $type !== "" ? $type : "unknown",
  296. "success" => false,
  297. "started_at" => $startedAt,
  298. "error" => $exception->getMessage(),
  299. ];
  300. if ($debugContext !== []) {
  301. $result["debug"] = $debugContext;
  302. }
  303. $results[] = $result;
  304. manageClientLog("ERROR", "Remote upload failed", [
  305. "target" => $label,
  306. "type" => $type !== "" ? $type : "unknown",
  307. "target_config" => manageRemoteSafeContext($target),
  308. "filename" => $metadata["filename"] ?? basename($archivePath),
  309. "error" => $exception->getMessage(),
  310. "debug" => $debugContext,
  311. ]);
  312. }
  313. }
  314. return $results;
  315. }