> */ public function all(): array { $stmt = $this->db->pdo()->query( 'SELECT h.*, a.display_name AS agent_name FROM host_configs h JOIN agents a ON a.id = h.agent_id ORDER BY h.agent_id ASC, h.sort_order ASC, h.id ASC' ); return $stmt->fetchAll(); } /** @return array> */ public function allForAgent(int $agentId): array { $stmt = $this->db->pdo()->prepare( 'SELECT * FROM host_configs WHERE agent_id = :agent_id ORDER BY sort_order ASC, id ASC' ); $stmt->execute(['agent_id' => $agentId]); return $stmt->fetchAll(); } /** @return array|null */ public function find(int $id): ?array { $stmt = $this->db->pdo()->prepare('SELECT * FROM host_configs WHERE id = :id'); $stmt->execute(['id' => $id]); $row = $stmt->fetch(); return $row === false ? null : $row; } /** @param array $data @return array */ public function create(array $data): array { $agentId = $this->validateAgent($data['agent_id'] ?? null); [$hostname, $address] = $this->validate($data, $agentId, null); $now = $this->db->now(); $stmt = $this->db->pdo()->prepare( 'INSERT INTO host_configs (agent_id, hostname, address, sort_order, created_at, updated_at) VALUES (:agent_id, :hostname, :address, :sort_order, :created_at, :updated_at)' ); $stmt->execute([ 'agent_id' => $agentId, 'hostname' => $hostname, 'address' => $address, 'sort_order' => $this->nextSortOrder($agentId), 'created_at' => $now, 'updated_at' => $now, ]); return $this->find((int) $this->db->pdo()->lastInsertId()); } /** @param array $data @return array */ public function update(int $id, array $data): array { $existing = $this->find($id); if ($existing === null) { throw new NotFoundException('Host nicht gefunden: ' . $id); } // Den Ping-Host wechselt man nicht per Bearbeiten-Formular: dafuer den Eintrag beim // alten Agenten loeschen und beim neuen anlegen (sonst waere die alte hosts.conf // stillschweigend um einen Host aermer, ohne dass die Zeile im Modal verschwindet). $agentId = (int) $existing['agent_id']; [$hostname, $address] = $this->validate($data, $agentId, $id); $stmt = $this->db->pdo()->prepare( 'UPDATE host_configs SET hostname = :hostname, address = :address, updated_at = :updated_at WHERE id = :id' ); $stmt->execute([ 'id' => $id, 'hostname' => $hostname, 'address' => $address, 'updated_at' => $this->db->now(), ]); return $this->find($id); } /** * @return array - der geloeschte Eintrag, damit der Aufrufer * (HostConfigController::delete() raeumt anschliessend die Kachel im System-Monitor ab) * dessen Hostnamen kennt, ohne ihn erneut nachzuschlagen. */ public function delete(int $id): array { $host = $this->find($id); if ($host === null) { throw new NotFoundException('Host nicht gefunden: ' . $id); } $stmt = $this->db->pdo()->prepare('DELETE FROM host_configs WHERE id = :id'); $stmt->execute(['id' => $id]); return $host; } /** * Erstbefuellung aus der bestehenden hosts.conf eines Ping-Rechners (check_fds.sh laedt * sie hoch, solange serverseitig noch nichts fuer ihn steht) - sonst muesste jeder * bereits ueberwachte Host von Hand im Modal nachgetragen werden. * * Bewusst nur, wenn die Liste dieses Agenten leer ist: ein spaeterer Aufruf darf eine im * Frontend gepflegte Liste nicht durch die (dann veraltete) lokale Datei ersetzen. * * @param array $rows * @return int Anzahl uebernommener Hosts (0 = es war bereits etwas konfiguriert). */ public function importForAgent(int $agentId, array $rows): int { if ($this->hasImported($agentId) || $this->allForAgent($agentId) !== []) { return 0; } // Der Marker steht vor dem Import: Auch ein Rechner, dessen hosts.conf leer oder // unbrauchbar war, hat seine Datei damit abgegeben - sonst wuerde er es bei jedem // Lauf erneut versuchen und irgendwann eine im Frontend geleerte Liste neu befuellen. $stmt = $this->db->pdo()->prepare( 'INSERT INTO host_config_imports (agent_id, imported_at) VALUES (:agent_id, :imported_at)' ); $stmt->execute(['agent_id' => $agentId, 'imported_at' => $this->db->now()]); $imported = 0; foreach ($rows as $row) { try { $this->create([ 'agent_id' => $agentId, 'hostname' => $row['hostname'] ?? '', 'address' => $row['address'] ?? '', ]); $imported++; } catch (InvalidArgumentException) { // Eine unbrauchbare oder doppelte Zeile darf den Import der uebrigen nicht // verhindern - die hosts.conf ist eine handgepflegte Datei. continue; } } return $imported; } /** Hat dieser Ping-Rechner seine lokale hosts.conf schon einmal abgegeben? */ public function hasImported(int $agentId): bool { $stmt = $this->db->pdo()->prepare('SELECT COUNT(*) FROM host_config_imports WHERE agent_id = :agent_id'); $stmt->execute(['agent_id' => $agentId]); return ((int) $stmt->fetchColumn()) > 0; } /** * Baut den Inhalt der hosts.conf fuer einen Ping-Rechner: eine Zeile "Hostname IP" je * Host, im Format, das check_fds.sh liest. */ public function renderHostsConf(int $agentId): string { $lines = [ '# Von der monitor-API erzeugt (Issue #70) - Aenderungen bitte im Frontend', '# vornehmen (Zahnrad am Wandmonitor "Systeme"), nicht hier: check_fds.sh', '# ueberschreibt diese Datei bei jedem Lauf mit der Liste aus der API.', ]; foreach ($this->allForAgent($agentId) as $host) { $lines[] = $host['hostname'] . ' ' . $host['address']; } return implode("\n", $lines) . "\n"; } /** * Liest eine hosts.conf im Format von check_fds.sh ("Hostname IP" je Zeile, "#" ist * Kommentar) - Gegenstueck zu renderHostsConf(), fuer die Erstbefuellung aus der bereits * gepflegten Datei eines Ping-Rechners. * * @return array */ public static function parseHostsConf(string $content): array { $rows = []; foreach (preg_split('/\r\n|\r|\n/', $content) ?: [] as $line) { $line = trim($line); if ($line === '' || str_starts_with($line, '#')) { continue; } $parts = preg_split('/\s+/', $line); if ($parts === false || count($parts) < 2) { continue; } $rows[] = ['hostname' => $parts[0], 'address' => $parts[1]]; } return $rows; } /** * Prueft Pflichtfelder und Format vorab, statt eine Verletzung der UNIQUE-Constraint * (agent_id, hostname) als PDOException auflaufen zu lassen - Konvention wie in * RepoConfigRepository::validate(). * * @param array $data * @param int|null $excludeId - beim Update die eigene Zeile von der Duplikatspruefung ausnehmen. * @return array{0: string, 1: string} */ private function validate(array $data, int $agentId, ?int $excludeId): array { $hostname = trim((string) ($data['hostname'] ?? '')); $address = trim((string) ($data['address'] ?? '')); if ($hostname === '' || $address === '') { throw new InvalidArgumentException('Felder "hostname" und "address" sind erforderlich.'); } // hosts.conf ist eine zeilen- und leerzeichengetrennte Datei: ein Wert mit Leerzeichen, // Zeilenumbruch oder fuehrendem "#" wuerde dort als zweites Feld bzw. als Kommentar // gelesen und den Eintrag stillschweigend verstuemmeln. foreach (['hostname' => $hostname, 'address' => $address] as $field => $value) { if (preg_match('/\s/', $value) === 1 || str_starts_with($value, '#')) { throw new InvalidArgumentException(sprintf( 'Feld "%s" darf keine Leerzeichen enthalten und nicht mit "#" beginnen.', $field )); } } if ($this->existsWithHostname($agentId, $hostname, $excludeId)) { throw new InvalidArgumentException(sprintf('Host "%s" ist bereits konfiguriert.', $hostname)); } return [$hostname, $address]; } /** Nur registrierte Agenten der Rolle "ops" pingen - alles andere waere eine Liste ohne Leser. */ private function validateAgent(mixed $agentId): int { $id = is_numeric($agentId) ? (int) $agentId : 0; if ($id <= 0) { throw new InvalidArgumentException('Feld "agent_id" ist erforderlich (OPs-Agent des Ping-Rechners).'); } $stmt = $this->db->pdo()->prepare('SELECT role FROM agents WHERE id = :id'); $stmt->execute(['id' => $id]); $role = $stmt->fetchColumn(); if ($role === false) { throw new InvalidArgumentException('Unbekannter Agent: ' . $id); } if ($role !== 'ops') { throw new InvalidArgumentException('Agent ' . $id . ' hat nicht die Rolle "ops".'); } return $id; } private function existsWithHostname(int $agentId, string $hostname, ?int $excludeId): bool { $sql = 'SELECT COUNT(*) FROM host_configs WHERE agent_id = :agent_id AND hostname = :hostname'; $params = ['agent_id' => $agentId, 'hostname' => $hostname]; if ($excludeId !== null) { $sql .= ' AND id != :exclude_id'; $params['exclude_id'] = $excludeId; } $stmt = $this->db->pdo()->prepare($sql); $stmt->execute($params); return ((int) $stmt->fetchColumn()) > 0; } private function nextSortOrder(int $agentId): int { $stmt = $this->db->pdo()->prepare( 'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM host_configs WHERE agent_id = :agent_id' ); $stmt->execute(['agent_id' => $agentId]); return (int) $stmt->fetch()['next']; } }