Merge pull request 'OPs-Agent repariert seine Praesenz nach dem naechsten Update selbst' (#87) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/87
This commit is contained in:
fds2610 2026-09-13 21:49:12 +02:00
commit 32e405252b
9 changed files with 181 additions and 2 deletions

17
API.md
View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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<string, mixed> $authenticatedAgent
*/
public function me(Request $request, array $authenticatedAgent): Response
{
return Response::json(['agent' => $this->agents->findById((int) $authenticatedAgent['id'])]);
}
}

View file

@ -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

View file

@ -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(

View file

@ -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']));

View file

@ -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']);
}
}

View file

@ -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