diff --git a/API.md b/API.md index 11f2692..478137a 100644 --- a/API.md +++ b/API.md @@ -130,6 +130,23 @@ allerdings nicht drin, da es erst bei der API-Antwort berechnet wird: sqlite3 "$MONITOR_DB_PATH" "SELECT id, display_name, last_seen_at FROM agents;" ``` +`last_seen_at` frischt nicht nur `POST /api/v1/agents/{id}/activity` auf, sondern **jeder** +mit gueltigem `X-Api-Key` authentifizierte Request (`AgentRepository::touch()`, Zustand und +Ticket bleiben dabei unveraendert). Wer sich mit seinem Key meldet, ist offensichtlich da - so +bleibt z.B. der OPs-Agent eines `check_fds.sh` mit aelterer `monitor-api.conf` ohne +`MONITOR_AGENT_ID` ueber seine Systemmeldungen praesent, obwohl er nie `activity` aufruft. + +### `GET /api/v1/agents/me` — Eigenen Agenten abfragen + +Auth: `X-Api-Key`. Response 200: `{ "agent": ‹Agent-Objekt, ohne api_key› }` des Agenten, +dem der Key gehoert. Dient Clients, die zwar ihren Key, aber nicht ihre ID kennen: +`check_fds.sh` traegt so eine in `monitor-api.conf` fehlende `MONITOR_AGENT_ID` selbst nach +(ohne Admin-Key, den der Ping-Rechner nicht hat). + +```bash +curl -s "$MONITOR_API_BASE/agents/me" -H "X-Api-Key: $MONITOR_API_KEY" | jq '.agent.id' +``` + ### `POST /api/v1/agents/{id}/activity` — Eigenen Zustand melden Auth: `X-Api-Key` des betroffenen Agenten (ein Agent darf nur seinen eigenen Zustand diff --git a/README.md b/README.md index 7557346..e94ac03 100644 --- a/README.md +++ b/README.md @@ -169,8 +169,12 @@ Ping-Überwachung normal weiter und protokolliert nur eine Warnung. Zusätzlich meldet `check_fds.sh` bei jedem Lauf `working` zu Beginn und `idle` am Ende (`POST /api/v1/agents/{id}/activity`, benötigt `MONITOR_AGENT_ID`) — ohne diese Meldung fiel der OPs-Agent zehn Minuten nach der Registrierung dauerhaft aus der Präsenz, obwohl der -Ping-Check längst lief (Issue #75). Fehlt `MONITOR_AGENT_ID`, bleibt nur diese Meldung aus, -der Rest von `check_fds.sh` läuft unverändert weiter. +Ping-Check längst lief (Issue #75). Fehlt `MONITOR_AGENT_ID` in einer älteren +`monitor-api.conf`, ermittelt `check_fds.sh` die ID beim nächsten Lauf selbst über +`GET /api/v1/agents/me` und trägt sie in die Datei nach — von Hand ist nichts zu tun. Unabhängig +davon hält schon jede Systemmeldung mit dem OPs-Key den Agenten präsent (die API frischt +`last_seen_at` bei jedem authentifizierten Request auf), selbst wenn auf dem Ping-Rechner noch +ein älteres `check_fds.sh` liegt. ## ⚙️ Hosts über die Oberfläche pflegen diff --git a/api/src/Application.php b/api/src/Application.php index 2cd4054..8c7b75d 100644 --- a/api/src/Application.php +++ b/api/src/Application.php @@ -52,10 +52,12 @@ final class Application private readonly AgentAuthenticator $agentAuth; private readonly AdminAuthenticator $adminAuth; private readonly WebhookAuthenticator $webhookAuth; + private readonly AgentRepository $agents; public function __construct(private readonly Database $db, private readonly Config $config) { $agents = new AgentRepository($db, $config->deskCount()); + $this->agents = $agents; $tickets = new TicketRepository($db); $events = new EventRepository($db); $systems = new SystemRepository($db); @@ -145,6 +147,12 @@ final class Application $this->router->add('GET', '/api/v1/agents', fn (Request $r, array $p) => $agentController->index($r)); $this->router->add('POST', '/api/v1/agents', fn (Request $r, array $p) => $agentController->register($r), 'admin'); + $this->router->add( + 'GET', + '/api/v1/agents/me', + fn (Request $r, array $p, array $agent) => $agentController->me($r, $agent), + 'agent' + ); $this->router->add( 'POST', '/api/v1/agents/{id}/activity', @@ -226,6 +234,8 @@ final class Application if ($agent === null) { return Response::error('Ungueltiger oder fehlender X-Api-Key-Header.', 401); } + // Jeder gueltige Key-Request zaehlt als Lebenszeichen (siehe AgentRepository::touch()). + $this->agents->touch((int) $agent['id']); } elseif ($match->auth === 'admin') { if (!$this->adminAuth->authenticate($request)) { return Response::error('Ungueltiger oder fehlender X-Admin-Key-Header.', 401); diff --git a/api/src/Controller/AgentController.php b/api/src/Controller/AgentController.php index 0a586b6..6b325a5 100644 --- a/api/src/Controller/AgentController.php +++ b/api/src/Controller/AgentController.php @@ -75,4 +75,17 @@ final class AgentController return Response::json(['agent' => $agent]); } + + /** + * Der zum X-Api-Key gehoerende Agent. check_fds.sh ermittelt daraus seine eigene ID, wenn + * sie in einer aelteren monitor-api.conf fehlt - ohne Admin-Key, den der Ping-Rechner + * nicht kennt. Neu gelesen statt $authenticatedAgent durchgereicht, weil der vor dem + * Auffrischen von last_seen_at geladen wurde und is_present sonst veraltet waere. + * + * @param array $authenticatedAgent + */ + public function me(Request $request, array $authenticatedAgent): Response + { + return Response::json(['agent' => $this->agents->findById((int) $authenticatedAgent['id'])]); + } } diff --git a/api/src/Repository/AgentRepository.php b/api/src/Repository/AgentRepository.php index c6aa9a7..e9224b2 100644 --- a/api/src/Repository/AgentRepository.php +++ b/api/src/Repository/AgentRepository.php @@ -108,6 +108,18 @@ final class AgentRepository return array_map(fn (array $row) => $this->hidingKey($row), $stmt->fetchAll()); } + /** + * Frischt nur last_seen_at auf, Zustand und Ticket bleiben unveraendert. Application ruft + * das bei jedem mit X-Api-Key authentifizierten Request auf: wer sich mit gueltigem Key + * meldet, ist offensichtlich da - auch wenn er (wie ein aelterer check_fds.sh ohne + * MONITOR_AGENT_ID) nie den Aktivitaets-Endpoint aufruft. + */ + public function touch(int $id): void + { + $stmt = $this->db->pdo()->prepare('UPDATE agents SET last_seen_at = :now WHERE id = :id'); + $stmt->execute(['now' => $this->db->now(), 'id' => $id]); + } + /** * Ein unbekanntes Ticket wuerde sonst erst in der FOREIGN-KEY-Constraint auflaufen und * als ungefangene PDOException beim Client landen - deshalb hier pruefen und als diff --git a/api/tests/Unit/ApplicationTest.php b/api/tests/Unit/ApplicationTest.php index fd2a88b..b39a614 100644 --- a/api/tests/Unit/ApplicationTest.php +++ b/api/tests/Unit/ApplicationTest.php @@ -99,6 +99,64 @@ final class ApplicationTest extends TestCase self::assertStringNotContainsString('PDOException', json_encode($response->body())); } + public function testAgentsMeRequiresAgentKey(): void + { + $response = $this->app->handle(Request::create('GET', '/api/v1/agents/me')); + + self::assertSame(401, $response->status); + } + + public function testAgentsMeReturnsOwnId(): void + { + $registerResponse = $this->app->handle(Request::create( + 'POST', + '/api/v1/agents', + body: ['display_name' => 'OPs', 'role' => 'ops'], + headers: ['X-Admin-Key' => 'admin-secret'] + )); + + $response = $this->app->handle(Request::create( + 'GET', + '/api/v1/agents/me', + headers: ['X-Api-Key' => $registerResponse->body()['api_key']] + )); + + self::assertSame(200, $response->status); + self::assertSame($registerResponse->body()['agent']['id'], $response->body()['agent']['id']); + } + + /** + * Regression: ein check_fds.sh mit monitor-api.conf ohne MONITOR_AGENT_ID meldete alle fuenf + * Minuten Systemstatus mit dem OPs-Key, rief aber nie den Aktivitaets-Endpoint auf - der + * OPs-Agent blieb dadurch seit seiner Registrierung dauerhaft abwesend. + */ + public function testAnyAuthenticatedAgentRequestRefreshesPresence(): void + { + $db = Database::inMemory(); + $app = new Application($db, new Config(['MONITOR_ADMIN_API_KEY' => 'admin-secret', 'MONITOR_STATUS_FILE' => '/does/not/exist'])); + $registerResponse = $app->handle(Request::create( + 'POST', + '/api/v1/agents', + body: ['display_name' => 'OPs', 'role' => 'ops'], + headers: ['X-Admin-Key' => 'admin-secret'] + )); + $agentId = $registerResponse->body()['agent']['id']; + + $db->pdo()->prepare('UPDATE agents SET last_seen_at = :ts WHERE id = :id') + ->execute(['ts' => gmdate('Y-m-d\TH:i:s\Z', time() - 601), 'id' => $agentId]); + self::assertFalse($app->handle(Request::create('GET', '/api/v1/agents'))->body()['agents'][0]['is_present']); + + $statusResponse = $app->handle(Request::create( + 'POST', + '/api/v1/systems/homeassistant/status', + body: ['status' => 'ok', 'name' => 'homeassistant'], + headers: ['X-Api-Key' => $registerResponse->body()['api_key']] + )); + self::assertSame(200, $statusResponse->status); + + self::assertTrue($app->handle(Request::create('GET', '/api/v1/agents'))->body()['agents'][0]['is_present']); + } + public function testPatchUnknownTicketReturns404(): void { $registerResponse = $this->app->handle(Request::create( diff --git a/api/tests/Unit/Controller/AgentControllerTest.php b/api/tests/Unit/Controller/AgentControllerTest.php index 0a8d713..af6617c 100644 --- a/api/tests/Unit/Controller/AgentControllerTest.php +++ b/api/tests/Unit/Controller/AgentControllerTest.php @@ -40,6 +40,18 @@ final class AgentControllerTest extends TestCase } } + public function testMeReturnsAuthenticatedAgentWithoutApiKeyHash(): void + { + $this->repository->register('Alice', 'entwickler'); + $ops = $this->repository->register('OPs', 'ops'); + + $response = $this->controller->me(Request::create('GET', '/api/v1/agents/me'), $ops['agent']); + + self::assertSame(200, $response->status); + self::assertSame($ops['agent']['id'], $response->body()['agent']['id']); + self::assertArrayNotHasKey('api_key_hash', $response->body()['agent']); + } + public function testRegisterRejectsMissingDisplayName(): void { $response = $this->controller->register(Request::create('POST', '/api/v1/agents', body: ['role' => 'entwickler'])); diff --git a/api/tests/Unit/Repository/AgentRepositoryTest.php b/api/tests/Unit/Repository/AgentRepositoryTest.php index a2d3230..075b469 100644 --- a/api/tests/Unit/Repository/AgentRepositoryTest.php +++ b/api/tests/Unit/Repository/AgentRepositoryTest.php @@ -157,4 +157,21 @@ final class AgentRepositoryTest extends TestCase $agent = $this->repository->findById($id); self::assertFalse($agent['is_present']); } + + public function testTouchRefreshesPresenceWithoutChangingState(): void + { + $result = $this->repository->register('OPs', 'ops'); + $id = (int) $result['agent']['id']; + $this->repository->updateActivity($id, 'auth_expiring', null); + + $staleTimestamp = gmdate('Y-m-d\TH:i:s\Z', time() - 601); + $this->db->pdo()->prepare('UPDATE agents SET last_seen_at = :ts WHERE id = :id') + ->execute(['ts' => $staleTimestamp, 'id' => $id]); + + $this->repository->touch($id); + + $agent = $this->repository->findById($id); + self::assertTrue($agent['is_present']); + self::assertSame('auth_expiring', $agent['state']); + } } diff --git a/check_fds.sh b/check_fds.sh index beb57ed..e1d7806 100755 --- a/check_fds.sh +++ b/check_fds.sh @@ -26,6 +26,41 @@ if [ -f "$API_CONF" ]; then . "$API_CONF" fi +# Aeltere monitor-api.conf (vor Issue #75) kennen MONITOR_AGENT_ID nicht - dann blieb die +# Zustandsmeldung unten stillschweigend aus. Die ID wird hier ueber GET /api/v1/agents/me mit dem +# eigenen Key ermittelt und in die Datei nachgetragen, damit sich der Ping-Rechner nach einem +# Update selbst repariert, ohne dass jemand register-ops-agent.sh (braucht den Admin-Key) laufen +# laesst. Schlaegt das fehl, laeuft die Ueberwachung unveraendert weiter. +resolve_agent_id() { + if [ -n "$MONITOR_AGENT_ID" ] || [ -z "$MONITOR_API_BASE" ] || [ -z "$MONITOR_API_KEY" ]; then + return 0 + fi + + local response id + if ! response=$(curl -s --fail --max-time 10 \ + -H "X-Api-Key: ${MONITOR_API_KEY}" \ + "${MONITOR_API_BASE%/}/api/v1/agents/me"); then + echo " Warnung: MONITOR_AGENT_ID fehlt und liess sich nicht von der API ermitteln, Zustand wird nicht gemeldet." + return 0 + fi + + # Das erste "id" der Antwort {"agent":{"id":2,...}} ist die Agenten-ID. + id=$(printf '%s' "$response" | grep -oE '"id": *[0-9]+' | head -n1 | grep -oE '[0-9]+' || true) + if [ -z "$id" ]; then + echo " Warnung: MONITOR_AGENT_ID fehlt, die API lieferte keine Agenten-ID." + return 0 + fi + + MONITOR_AGENT_ID="$id" + # Angehaengt statt ersetzt: eine spaetere Zuweisung gewinnt beim naechsten Sourcen auch + # gegen ein vorhandenes MONITOR_AGENT_ID="". + if printf '\n# Von check_fds.sh nachgetragen (fehlte, Issue #75)\nMONITOR_AGENT_ID="%s"\n' "$id" >> "$API_CONF" 2>/dev/null; then + echo " MONITOR_AGENT_ID fehlte in $API_CONF und wurde nachgetragen (ID $id)." + else + echo " Warnung: MONITOR_AGENT_ID ($id) liess sich nicht in $API_CONF schreiben, gilt nur fuer diesen Lauf." + fi +} + # Issue #70: Die Liste der ueberwachten Hosts wird im Frontend gepflegt (Zahnrad am # Wandmonitor "Systeme"). Dieser Aufruf holt sie vor jedem Durchlauf ab und schreibt daraus # die lokale hosts.conf - deshalb muss auf diesem Rechner nichts mehr von Hand editiert @@ -221,6 +256,7 @@ echo "Pruefung: $(date)" # hosts.conf/mail_fds.py weiter unten) nicht dauerhaft als "working" haengen bleibt - # analog zum Cron-Skript des Entwickler-Agenten, das ebenfalls working/idle um seinen Lauf # herum meldet (Issue #75). +resolve_agent_id trap 'report_activity idle' EXIT report_activity working