monitor/api/src/Repository/HostConfigRepository.php
fds2610-bot 55d5c34ca6 Ueberwachte Hosts ueber ein Zahnrad am System-Monitor pflegen (Issue #70)
Wer einen Server aus der Ping-Ueberwachung nehmen wollte, musste sich bisher auf den
Rechner mit check_fds.sh einloggen und die hosts.conf editieren - und die Kachel blieb
danach trotzdem im System-Monitor stehen, weil ein einmal gemeldetes System nirgends
wieder geloescht werden konnte.

Die Liste steht jetzt in der Datenbank und ist ueber ein Zahnrad neben der Ueberschrift
"Systeme" pflegbar, analog zum Zahnrad am PO-Platz (Issue #27). Jeder Host gehoert dem
OPs-Agenten (Issue #66), der ihn anpingt: API und Ping-Check laufen nicht zwangslaeufig
auf demselben Rechner, die API kann die hosts.conf also nicht selbst schreiben.
check_fds.sh holt sie stattdessen bei jedem Lauf mit seinem Agenten-Key ab und schreibt
daraus die lokale Datei; ist die API nicht erreichbar, pingt es unveraendert nach der
zuletzt geschriebenen Liste weiter.

Beim ersten Lauf laedt der Check seine bestehende hosts.conf hoch, damit die bereits
ueberwachten Server nicht von Hand nachgetragen werden muessen. Dass die API das nur
einmal annimmt (Marker in host_config_imports) und "noch nie konfiguriert" per 204 von
"im Frontend geleert" per 200 unterscheidet, ist noetig, damit ein Ping-Rechner mit alter
lokaler Datei die dort geloeschten Hosts nicht wieder anlegt.

Loeschen raeumt mit auf: Ein entfernter Host nimmt seine Kachel mit - beide Ids, denn der
status.txt-Import benennt ihn nach dem Hostnamen, die REST-Meldung nach dessen Slug.
Kacheln ohne zugehoerigen Host (Selbstmelder oder Reste laengst entfernter Hosts) stehen
im selben Dialog unter "Weitere Kacheln im System-Monitor" und sind dort einzeln
loeschbar - dafuer gibt es neu DELETE /api/v1/systems/{id}.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vNjbfr8M3HSfWMee92GTE
2026-09-01 13:14:11 +02:00

312 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace Monitor\Api\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\NotFoundException;
/**
* Verwaltet die Hosts der Ping-Ueberwachung (Issue #70: vorher ausschliesslich die hosts.conf
* neben check_fds.sh, siehe 005_host_configs.sql). Jede Zeile gehoert dem OPs-Agenten, der
* diesen Host anpingt; check_fds.sh holt seine Liste bei jedem Lauf ueber den eigenen
* Agenten-Key ab, damit eine Aenderung im Frontend ohne Login auf dem Ping-Rechner wirkt.
*/
final class HostConfigRepository
{
public function __construct(private readonly Database $db)
{
}
/**
* Alle Hosts ueber alle Ping-Hosts hinweg, inklusive Anzeigename des zustaendigen
* Agenten - so kann das Frontend-Modal ohne zweiten Request zeigen, wer den Host prueft.
*
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> */
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<string, mixed>|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<string, mixed> $data @return array<string, mixed> */
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<string, mixed> $data @return array<string, mixed> */
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<string, mixed> - 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<int, array{hostname: string, address: string}> $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<int, array{hostname: string, address: string}>
*/
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<string, mixed> $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'];
}
}