instances.php 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. <?php
  2. declare(strict_types=1);
  3. // Instance registry. Replaces the backup server's plain name allowlist and
  4. // gives the update server the client identity it never had. One record per
  5. // deployed client instance, stored in storage/instances.json.
  6. //
  7. // Tokens are stored as a SHA-256 hash only. The plaintext is returned exactly
  8. // once, when it is created or rotated, and can never be recovered afterwards.
  9. require_once __DIR__ . "/bootstrap.php";
  10. function manageInstanceValidateId(string $id): string
  11. {
  12. $id = trim($id);
  13. if (
  14. $id === "" ||
  15. strlen($id) > 120 ||
  16. preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $id) !== 1
  17. ) {
  18. throw new RuntimeException(
  19. "Ungültige Instanz-Kennung. Erlaubt sind Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich.",
  20. );
  21. }
  22. return $id;
  23. }
  24. function manageInstanceGenerateToken(): string
  25. {
  26. return bin2hex(random_bytes(32));
  27. }
  28. function manageInstanceHashToken(string $token): string
  29. {
  30. return hash("sha256", $token);
  31. }
  32. // Fills in every field a caller may read, so the rest of the code never has to
  33. // guard against records written by an older version.
  34. function manageInstanceNormalize(array $record): array
  35. {
  36. return [
  37. "id" => (string) ($record["id"] ?? ""),
  38. "label" => (string) ($record["label"] ?? ""),
  39. "enabled" => (bool) ($record["enabled"] ?? true),
  40. "token_hash" => (string) ($record["token_hash"] ?? ""),
  41. "created_at" => (string) ($record["created_at"] ?? ""),
  42. "token_rotated_at" => (string) ($record["token_rotated_at"] ?? ""),
  43. "last_seen_at" => (string) ($record["last_seen_at"] ?? ""),
  44. "last_ip" => (string) ($record["last_ip"] ?? ""),
  45. "version" => (string) ($record["version"] ?? ""),
  46. "php_version" => (string) ($record["php_version"] ?? ""),
  47. "disk_free" => (int) ($record["disk_free"] ?? 0),
  48. "pending_migrations" => (int) ($record["pending_migrations"] ?? 0),
  49. "last_backup_at" => (string) ($record["last_backup_at"] ?? ""),
  50. "backup_count" => (int) ($record["backup_count"] ?? 0),
  51. "notes" => (string) ($record["notes"] ?? ""),
  52. ];
  53. }
  54. function manageInstanceList(): array
  55. {
  56. $data = manageReadJsonFile(manageInstancesFile());
  57. $records = isset($data["instances"]) && is_array($data["instances"])
  58. ? $data["instances"]
  59. : [];
  60. $instances = [];
  61. foreach ($records as $record) {
  62. if (!is_array($record)) {
  63. continue;
  64. }
  65. $normalized = manageInstanceNormalize($record);
  66. if ($normalized["id"] === "") {
  67. continue;
  68. }
  69. $instances[] = $normalized;
  70. }
  71. usort($instances, static function (array $left, array $right): int {
  72. return strcmp($left["id"], $right["id"]);
  73. });
  74. return $instances;
  75. }
  76. function manageInstanceWriteAll(array $instances): void
  77. {
  78. manageWriteJsonFile(manageInstancesFile(), [
  79. "instances" => array_values($instances),
  80. ]);
  81. }
  82. function manageInstanceFind(string $id): ?array
  83. {
  84. foreach (manageInstanceList() as $instance) {
  85. if ($instance["id"] === $id) {
  86. return $instance;
  87. }
  88. }
  89. return null;
  90. }
  91. function manageInstanceExists(string $id): bool
  92. {
  93. return manageInstanceFind($id) !== null;
  94. }
  95. /**
  96. * @return array{instance: array, token: string} the plaintext token is shown once
  97. */
  98. function manageInstanceCreate(string $id, string $label = "", string $notes = ""): array
  99. {
  100. $id = manageInstanceValidateId($id);
  101. if (manageInstanceExists($id)) {
  102. throw new RuntimeException("Eine Instanz mit dieser Kennung existiert bereits.");
  103. }
  104. $token = manageInstanceGenerateToken();
  105. $instance = manageInstanceNormalize([
  106. "id" => $id,
  107. "label" => trim($label),
  108. "enabled" => true,
  109. "token_hash" => manageInstanceHashToken($token),
  110. "created_at" => date(DATE_ATOM),
  111. "token_rotated_at" => date(DATE_ATOM),
  112. "notes" => trim($notes),
  113. ]);
  114. $instances = manageInstanceList();
  115. $instances[] = $instance;
  116. manageInstanceWriteAll($instances);
  117. manageLogAccess("Instance created", ["instance" => $id]);
  118. return ["instance" => $instance, "token" => $token];
  119. }
  120. // Applies a partial update to one instance. Unknown keys are ignored, so a
  121. // caller can hand over a heartbeat payload directly.
  122. function manageInstanceUpdate(string $id, array $changes): array
  123. {
  124. $instances = manageInstanceList();
  125. $updated = null;
  126. foreach ($instances as $position => $instance) {
  127. if ($instance["id"] !== $id) {
  128. continue;
  129. }
  130. foreach ($changes as $key => $value) {
  131. if ($key === "id" || $key === "token_hash" || !array_key_exists($key, $instance)) {
  132. continue;
  133. }
  134. $instance[$key] = $value;
  135. }
  136. $updated = manageInstanceNormalize($instance);
  137. $instances[$position] = $updated;
  138. break;
  139. }
  140. if ($updated === null) {
  141. throw new RuntimeException("Instanz wurde nicht gefunden: " . $id);
  142. }
  143. manageInstanceWriteAll($instances);
  144. return $updated;
  145. }
  146. function manageInstanceRotateToken(string $id): string
  147. {
  148. $instances = manageInstanceList();
  149. $token = manageInstanceGenerateToken();
  150. $found = false;
  151. foreach ($instances as $position => $instance) {
  152. if ($instance["id"] !== $id) {
  153. continue;
  154. }
  155. $instance["token_hash"] = manageInstanceHashToken($token);
  156. $instance["token_rotated_at"] = date(DATE_ATOM);
  157. $instances[$position] = $instance;
  158. $found = true;
  159. break;
  160. }
  161. if (!$found) {
  162. throw new RuntimeException("Instanz wurde nicht gefunden: " . $id);
  163. }
  164. manageInstanceWriteAll($instances);
  165. manageLogAccess("Instance token rotated", ["instance" => $id]);
  166. return $token;
  167. }
  168. // Removes the registry entry. Stored backups are kept on purpose: a deleted
  169. // instance can no longer upload, but its history stays available for download.
  170. function manageInstanceDelete(string $id): void
  171. {
  172. $instances = manageInstanceList();
  173. $remaining = [];
  174. $found = false;
  175. foreach ($instances as $instance) {
  176. if ($instance["id"] === $id) {
  177. $found = true;
  178. continue;
  179. }
  180. $remaining[] = $instance;
  181. }
  182. if (!$found) {
  183. throw new RuntimeException("Instanz wurde nicht gefunden: " . $id);
  184. }
  185. manageInstanceWriteAll($remaining);
  186. manageLogAccess("Instance deleted", ["instance" => $id]);
  187. }
  188. /**
  189. * Constant-time token check.
  190. *
  191. * @return array|null the instance record, or null when id/token do not match
  192. */
  193. function manageInstanceAuthenticate(string $id, string $token): ?array
  194. {
  195. $instance = manageInstanceFind($id);
  196. if ($instance === null || $instance["token_hash"] === "") {
  197. return null;
  198. }
  199. if (!hash_equals($instance["token_hash"], manageInstanceHashToken($token))) {
  200. return null;
  201. }
  202. return $instance;
  203. }
  204. // Records what a client reported. Called from every authenticated API request
  205. // so the dashboard stays current even without an explicit heartbeat.
  206. function manageInstanceTouch(string $id, array $report = []): void
  207. {
  208. $changes = [
  209. "last_seen_at" => date(DATE_ATOM),
  210. "last_ip" => (string) ($_SERVER["REMOTE_ADDR"] ?? ""),
  211. ];
  212. foreach (["version", "php_version", "disk_free", "pending_migrations"] as $key) {
  213. if (array_key_exists($key, $report)) {
  214. $changes[$key] = $report[$key];
  215. }
  216. }
  217. try {
  218. manageInstanceUpdate($id, $changes);
  219. } catch (Throwable $exception) {
  220. // A missing instance cannot happen here (the caller authenticated
  221. // first) and a failed status write must never break the request.
  222. manageLogError("Instance touch failed", [
  223. "instance" => $id,
  224. "error" => $exception->getMessage(),
  225. ]);
  226. }
  227. }