120 || preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $id) !== 1 ) { throw new RuntimeException( "Ungültige Instanz-Kennung. Erlaubt sind Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich.", ); } return $id; } function manageInstanceGenerateToken(): string { return bin2hex(random_bytes(32)); } function manageInstanceHashToken(string $token): string { return hash("sha256", $token); } // Fills in every field a caller may read, so the rest of the code never has to // guard against records written by an older version. function manageInstanceNormalize(array $record): array { return [ "id" => (string) ($record["id"] ?? ""), "label" => (string) ($record["label"] ?? ""), "enabled" => (bool) ($record["enabled"] ?? true), "token_hash" => (string) ($record["token_hash"] ?? ""), "created_at" => (string) ($record["created_at"] ?? ""), "token_rotated_at" => (string) ($record["token_rotated_at"] ?? ""), "last_seen_at" => (string) ($record["last_seen_at"] ?? ""), "last_ip" => (string) ($record["last_ip"] ?? ""), "version" => (string) ($record["version"] ?? ""), "php_version" => (string) ($record["php_version"] ?? ""), "disk_free" => (int) ($record["disk_free"] ?? 0), "pending_migrations" => (int) ($record["pending_migrations"] ?? 0), "last_backup_at" => (string) ($record["last_backup_at"] ?? ""), "backup_count" => (int) ($record["backup_count"] ?? 0), "notes" => (string) ($record["notes"] ?? ""), ]; } function manageInstanceList(): array { $data = manageReadJsonFile(manageInstancesFile()); $records = isset($data["instances"]) && is_array($data["instances"]) ? $data["instances"] : []; $instances = []; foreach ($records as $record) { if (!is_array($record)) { continue; } $normalized = manageInstanceNormalize($record); if ($normalized["id"] === "") { continue; } $instances[] = $normalized; } usort($instances, static function (array $left, array $right): int { return strcmp($left["id"], $right["id"]); }); return $instances; } function manageInstanceWriteAll(array $instances): void { manageWriteJsonFile(manageInstancesFile(), [ "instances" => array_values($instances), ]); } function manageInstanceFind(string $id): ?array { foreach (manageInstanceList() as $instance) { if ($instance["id"] === $id) { return $instance; } } return null; } function manageInstanceExists(string $id): bool { return manageInstanceFind($id) !== null; } /** * @return array{instance: array, token: string} the plaintext token is shown once */ function manageInstanceCreate(string $id, string $label = "", string $notes = ""): array { $id = manageInstanceValidateId($id); if (manageInstanceExists($id)) { throw new RuntimeException("Eine Instanz mit dieser Kennung existiert bereits."); } $token = manageInstanceGenerateToken(); $instance = manageInstanceNormalize([ "id" => $id, "label" => trim($label), "enabled" => true, "token_hash" => manageInstanceHashToken($token), "created_at" => date(DATE_ATOM), "token_rotated_at" => date(DATE_ATOM), "notes" => trim($notes), ]); $instances = manageInstanceList(); $instances[] = $instance; manageInstanceWriteAll($instances); manageLogAccess("Instance created", ["instance" => $id]); return ["instance" => $instance, "token" => $token]; } // Applies a partial update to one instance. Unknown keys are ignored, so a // caller can hand over a heartbeat payload directly. function manageInstanceUpdate(string $id, array $changes): array { $instances = manageInstanceList(); $updated = null; foreach ($instances as $position => $instance) { if ($instance["id"] !== $id) { continue; } foreach ($changes as $key => $value) { if ($key === "id" || $key === "token_hash" || !array_key_exists($key, $instance)) { continue; } $instance[$key] = $value; } $updated = manageInstanceNormalize($instance); $instances[$position] = $updated; break; } if ($updated === null) { throw new RuntimeException("Instanz wurde nicht gefunden: " . $id); } manageInstanceWriteAll($instances); return $updated; } function manageInstanceRotateToken(string $id): string { $instances = manageInstanceList(); $token = manageInstanceGenerateToken(); $found = false; foreach ($instances as $position => $instance) { if ($instance["id"] !== $id) { continue; } $instance["token_hash"] = manageInstanceHashToken($token); $instance["token_rotated_at"] = date(DATE_ATOM); $instances[$position] = $instance; $found = true; break; } if (!$found) { throw new RuntimeException("Instanz wurde nicht gefunden: " . $id); } manageInstanceWriteAll($instances); manageLogAccess("Instance token rotated", ["instance" => $id]); return $token; } // Removes the registry entry. Stored backups are kept on purpose: a deleted // instance can no longer upload, but its history stays available for download. function manageInstanceDelete(string $id): void { $instances = manageInstanceList(); $remaining = []; $found = false; foreach ($instances as $instance) { if ($instance["id"] === $id) { $found = true; continue; } $remaining[] = $instance; } if (!$found) { throw new RuntimeException("Instanz wurde nicht gefunden: " . $id); } manageInstanceWriteAll($remaining); manageLogAccess("Instance deleted", ["instance" => $id]); } /** * Constant-time token check. * * @return array|null the instance record, or null when id/token do not match */ function manageInstanceAuthenticate(string $id, string $token): ?array { $instance = manageInstanceFind($id); if ($instance === null || $instance["token_hash"] === "") { return null; } if (!hash_equals($instance["token_hash"], manageInstanceHashToken($token))) { return null; } return $instance; } // Records what a client reported. Called from every authenticated API request // so the dashboard stays current even without an explicit heartbeat. function manageInstanceTouch(string $id, array $report = []): void { $changes = [ "last_seen_at" => date(DATE_ATOM), "last_ip" => (string) ($_SERVER["REMOTE_ADDR"] ?? ""), ]; foreach (["version", "php_version", "disk_free", "pending_migrations"] as $key) { if (array_key_exists($key, $report)) { $changes[$key] = $report[$key]; } } try { manageInstanceUpdate($id, $changes); } catch (Throwable $exception) { // A missing instance cannot happen here (the caller authenticated // first) and a failed status write must never break the request. manageLogError("Instance touch failed", [ "instance" => $id, "error" => $exception->getMessage(), ]); } }