| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270 |
- <?php
- declare(strict_types=1);
- // Instance registry. Replaces the backup server's plain name allowlist and
- // gives the update server the client identity it never had. One record per
- // deployed client instance, stored in storage/instances.json.
- //
- // Tokens are stored as a SHA-256 hash only. The plaintext is returned exactly
- // once, when it is created or rotated, and can never be recovered afterwards.
- require_once __DIR__ . "/bootstrap.php";
- function manageInstanceValidateId(string $id): string
- {
- $id = trim($id);
- if (
- $id === "" ||
- strlen($id) > 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(),
- ]);
- }
- }
|