From 3efa0ee42394a65605b433df1b67192da0718bf6 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 29 Jul 2026 21:03:44 +0200 Subject: [PATCH] Codeberg-Webhook fuer Sofort-Sync statt Warten auf den Polling-Lauf Aenderungen an Codeberg-Issues landeten bisher erst beim naechsten Lauf des sync-Services auf dem Board (MONITOR_SYNC_INTERVAL, Default 600s). Neu meldet Codeberg jede Aenderung aktiv per Webhook, der Weg aufs Board dauert damit wenige Sekunden (Webhook -> Sync -> SSE-Push ans Frontend). POST /api/v1/webhooks/codeberg wertet den Payload bewusst nicht aus, sondern fordert nur einen regulaeren Sync an - CodebergSyncService bleibt die einzige Stelle, die Issues auf Tickets abbildet, und ein verpasster Webhook ist folgenlos, weil der periodische Lauf denselben Stand ohnehin nachholt. Der Sync passiert nicht im Request: Forgejo bricht die Zustellung nach wenigen Sekunden ab (DELIVER_TIMEOUT, Default 5s), ein Sync macht aber mehrere Requests nach codeberg.org. Der Endpunkt setzt daher nur eine Trigger-Datei im gemeinsamen ./api-Volume (Sync\SyncTrigger) und antwortet sofort mit 202; der sync-Service laeuft jetzt im Watch-Modus und greift sie im Sekundentakt auf. Authentifiziert wird per HMAC-SHA256 ueber den rohen Request-Body (X-Forgejo-Signature bzw. X-Gitea-Signature) - Codeberg kennt unsere API-Keys nicht. Ohne gesetztes CODEBERG_WEBHOOK_SECRET weist der Endpunkt grundsaetzlich mit 401 ab, damit ein vergessenes Secret keinen offenen Sync-Ausloeser im Internet hinterlaesst. Request traegt dafuer neu den unveraenderten rawBody. Anders als der sync-Service braucht der api-Service damit eine eingehende Freigabe in Proxy und Firewall (POST /api/v1/webhooks/codeberg). Co-Authored-By: Claude Opus 5 --- .env.example | 13 ++- API.md | 48 ++++++++- README.md | 7 +- api/bin/sync-codeberg.php | 37 ++++++- api/src/Application.php | 19 ++++ api/src/Auth/WebhookAuthenticator.php | 52 ++++++++++ api/src/Config.php | 29 ++++++ api/src/Controller/WebhookController.php | 41 ++++++++ api/src/Http/Request.php | 31 ++++-- api/src/Sync/SyncTrigger.php | 50 ++++++++++ api/tests/Unit/ApplicationTest.php | 41 ++++++++ .../Unit/Auth/WebhookAuthenticatorTest.php | 98 +++++++++++++++++++ .../Unit/Controller/WebhookControllerTest.php | 79 +++++++++++++++ api/tests/Unit/Sync/SyncTriggerTest.php | 62 ++++++++++++ docker-compose.yml | 34 ++++--- 15 files changed, 610 insertions(+), 31 deletions(-) create mode 100644 api/src/Auth/WebhookAuthenticator.php create mode 100644 api/src/Controller/WebhookController.php create mode 100644 api/src/Sync/SyncTrigger.php create mode 100644 api/tests/Unit/Auth/WebhookAuthenticatorTest.php create mode 100644 api/tests/Unit/Controller/WebhookControllerTest.php create mode 100644 api/tests/Unit/Sync/SyncTriggerTest.php diff --git a/.env.example b/.env.example index 8abea45..723ae1c 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,20 @@ CODEBERG_REPO=monitor # MONITOR_SYNC_INTERVAL (haeufigem Sync) empfehlenswert CODEBERG_TOKEN= -# Abstand zwischen zwei Codeberg-Sync-Laeufen in Sekunden (Service "sync" in +# Abstand zwischen zwei periodischen Codeberg-Sync-Laeufen in Sekunden (Service "sync" in # docker-compose.yml). Der erste Lauf passiert direkt beim Start des Services. +# Mit eingerichtetem Webhook (siehe unten) ist das nur noch das Sicherheitsnetz fuer +# verpasste Zustellungen und darf gross bleiben - Issue-Aenderungen landen dann in Sekunden +# auf dem Board, nicht erst beim naechsten Lauf. # Speist ausserdem den rein kosmetischen Reload-Countdown im Header der Buero-Visualisierung # (Service "web", siehe docker/nginx/default.conf.template) - der Countdown zaehlt nur mit, # er loest selbst keinen echten Reload/Sync aus. MONITOR_SYNC_INTERVAL=600 + +# Secret des Codeberg-Webhooks (Repo -> Einstellungen -> Webhooks -> Forgejo, Ziel-URL +# /api/v1/webhooks/codeberg, Events "Issues" und "Issue-Label"). +# Codeberg signiert damit jeden Payload; ohne gesetztes Secret weist die API den Endpunkt +# grundsaetzlich mit 401 ab. Erzeugen z.B. mit: openssl rand -hex 32 +# Der Endpunkt ist der einzige, den Codeberg von aussen erreichen muss - Proxy und Firewall +# muessen POST /api/v1/webhooks/codeberg also aus dem Internet durchlassen. +CODEBERG_WEBHOOK_SECRET= diff --git a/API.md b/API.md index 41045d5..a963b1a 100644 --- a/API.md +++ b/API.md @@ -48,6 +48,11 @@ Zwei Schluesseltypen, beide statisch (F14): | `X-Admin-Key` | `POST /api/v1/agents` (Registrierung neuer Agenten) | Umgebungsvariable `MONITOR_ADMIN_API_KEY` | | `X-Api-Key` | Alle anderen schreibenden Endpunkte | Wird bei der Agenten-Registrierung einmalig zurueckgegeben | +Ausnahme ist der Codeberg-Webhook (`POST /api/v1/webhooks/codeberg`): Codeberg kennt unsere +Keys nicht, signiert den Payload aber mit dem hinterlegten Webhook-Secret - dort gilt die +HMAC-Signatur statt eines Keys, siehe +[Codeberg-Sync](#post-apiv1webhookscodeberg--sofort-sync-per-webhook). + Der Klartext-API-Key eines Agenten wird **nur einmal** bei der Registrierung ausgegeben. Gespeichert wird ausschliesslich ein SHA-256-Hash in SQLite (Tabelle `agents`, Spalte `api_key_hash`) - der Klartext ist danach nicht mehr rekonstruierbar und muss vom @@ -274,10 +279,11 @@ Codeberg-Repo (`CODEBERG_OWNER`/`CODEBERG_REPO`) und gleicht sie ueber `external Ausgefuehrt wird das Skript vom Compose-Service `sync` (siehe [docker-compose.yml](docker-compose.yml)) - kein Host-Cron noetig. Der Service startet mit -dem Stack, macht sofort einen ersten Lauf und wiederholt ihn dann alle -`MONITOR_SYNC_INTERVAL` Sekunden (Default 600, siehe `.env.example`). Er nutzt dasselbe -Image und dieselbe SQLite-Datei wie `api` und verbindet sich ausschliesslich ausgehend nach -codeberg.org - es ist also kein Port-Mapping und keine Firewall-Freigabe noetig. +dem Stack (`--watch`), macht sofort einen ersten Lauf und wiederholt ihn danach aus zwei +Gruenden: periodisch alle `MONITOR_SYNC_INTERVAL` Sekunden (Default 600, siehe +`.env.example`) und **sofort**, sobald ein Codeberg-Webhook eintrifft (siehe unten). Er nutzt +dasselbe Image und dieselbe SQLite-Datei wie `api` und verbindet sich selbst ausschliesslich +ausgehend nach codeberg.org - fuer diesen Service ist also kein Port-Mapping noetig. ```bash docker compose up -d sync # Sync-Service starten @@ -289,6 +295,40 @@ Beim manuellen Aufruf werden Fehler direkt auf der Konsole sichtbar (`Codeberg-A erreichbar: …` bzw. `Codeberg-API-Fehler (HTTP 4xx)`) - der erste Anlaufpunkt, wenn Backlog und Done leer bleiben. +### `POST /api/v1/webhooks/codeberg` — Sofort-Sync per Webhook + +Damit Issue-Aenderungen nicht erst beim naechsten periodischen Lauf auf dem Board landen, +kann Codeberg jede Aenderung aktiv melden. Der Endpunkt wertet den Payload **nicht** aus, er +fordert nur einen regulaeren Sync an; der `sync`-Service greift die Anforderung im +Sekundentakt auf und laedt den aktuellen Stand. Vom Aendern des Issues bis zum Board +vergehen so wenige Sekunden (Webhook → Sync → SSE-Push ans Frontend). + +Authentifizierung ist **kein** API-Key, sondern die HMAC-Signatur, die Codeberg selbst +mitschickt: `X-Forgejo-Signature` (bzw. `X-Gitea-Signature`) enthaelt den HMAC-SHA256 des +rohen Request-Bodys, gebildet mit dem Webhook-Secret aus `CODEBERG_WEBHOOK_SECRET`. Ist die +Variable nicht gesetzt, antwortet der Endpunkt **grundsaetzlich** mit 401 - ein offener +Sync-Ausloeser im Internet waere sonst die Folge. + +| Antwort | Bedeutung | +|---------|-----------| +| `202` mit `{"status": "sync angefordert"}` | Sync wurde angestossen | +| `200` mit `{"status": "pong"}` | `ping`-Event beim Anlegen des Webhooks | +| `401` | Signatur fehlt, ist falsch oder es ist kein Secret konfiguriert | + +Einrichtung im Codeberg-Repo unter **Einstellungen → Webhooks → Forgejo**: + +- **Ziel-URL:** `https:///api/v1/webhooks/codeberg` +- **HTTP-Methode:** `POST`, Content-Type `application/json` +- **Secret:** derselbe Wert wie `CODEBERG_WEBHOOK_SECRET` (z.B. `openssl rand -hex 32`) +- **Ereignisse:** *Benutzerdefiniert* → `Issues` und `Issue-Label` (Issue-Kommentare sind + fuer das Board irrelevant und wuerden nur ueberfluessige Syncs ausloesen) + +Anders als der `sync`-Service braucht **dieser** Endpunkt eine eingehende Freigabe: Proxy und +Firewall muessen `POST /api/v1/webhooks/codeberg` aus dem Internet an den `api`-Container +durchlassen. Ob eine Zustellung geklappt hat, zeigt Codeberg direkt beim Webhook unter +„Letzte Zustellungen" (inkl. Antwort-Body). Der periodische Sync laeuft unabhaengig weiter +und ist das Sicherheitsnetz fuer verpasste Zustellungen (Container-Neustart, Stoerung). + ## Fehlerformat Alle Fehlerantworten haben die Form `{ "error": "‹Meldung›" }` mit passendem HTTP-Status: diff --git a/README.md b/README.md index bfcf13b..6f65613 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,11 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und ersatzweise Demo-Daten — kenntlich gemacht durch einen roten Banner „API nicht erreichbar!". Die Basis-URL der API kommt aus `MONITOR_API_BASE` (siehe `.env.example`). - Codeberg-Sync als Dauerläufer (Compose-Service `sync`): füllt Backlog und Done automatisch - alle `MONITOR_SYNC_INTERVAL` Sekunden aus den Issues des konfigurierten Codeberg-Repos - (siehe [API.md](API.md) Abschnitt „Codeberg-Sync") + aus den Issues des konfigurierten Codeberg-Repos — periodisch alle `MONITOR_SYNC_INTERVAL` + Sekunden und, wenn ein Webhook eingerichtet ist, innerhalb von Sekunden nach jeder + Issue-Änderung (`POST /api/v1/webhooks/codeberg`, HMAC-signiert per + `CODEBERG_WEBHOOK_SECRET`). Siehe [API.md](API.md) Abschnitt „Codeberg-Sync" — + dort steht auch die Einrichtung im Codeberg-Repo. - Weiterhin: klassische Ping-Überwachung (`check_fds.sh`, `hosts.conf`, `mail_fds.py`) als eine Datenquelle für den System-Monitor; die alte tabellarische Statusseite ist mit Phase 7 entfallen (F16), `status.txt` speist weiterhin den System-Monitor diff --git a/api/bin/sync-codeberg.php b/api/bin/sync-codeberg.php index 61ed31e..cf8f871 100755 --- a/api/bin/sync-codeberg.php +++ b/api/bin/sync-codeberg.php @@ -8,6 +8,7 @@ use Monitor\Api\Db\Database; use Monitor\Api\Repository\TicketRepository; use Monitor\Api\Sync\CodebergSyncService; use Monitor\Api\Sync\CurlCodebergClient; +use Monitor\Api\Sync\SyncTrigger; require dirname(__DIR__) . '/vendor/autoload.php'; @@ -21,6 +22,38 @@ $service = new CodebergSyncService( $config->codebergRepo(), ); -$service->sync(); +// Ohne --watch bleibt das Skript ein einmaliger Lauf (manueller Aufruf, siehe README). +if (!in_array('--watch', $argv, true)) { + $service->sync(); + fwrite(STDOUT, "Codeberg-Sync abgeschlossen.\n"); -fwrite(STDOUT, "Codeberg-Sync abgeschlossen.\n"); + return; +} + +$trigger = new SyncTrigger($config->syncTriggerPath()); +$interval = $config->syncIntervalSeconds(); + +fwrite(STDOUT, sprintf("[sync] Watch-Modus, periodischer Lauf alle %ds, Webhook-Trigger sofort.\n", $interval)); + +$lastRun = 0; +while (true) { + $triggered = $trigger->consume(); + $due = (time() - $lastRun) >= $interval; + + if ($triggered || $due) { + $lastRun = time(); + try { + $service->sync(); + fwrite(STDOUT, sprintf("[sync] Lauf beendet (%s).\n", $triggered ? 'Webhook' : 'periodisch')); + } catch (Throwable $e) { + // Ein fehlgeschlagener Lauf (Codeberg nicht erreichbar, Rate-Limit) darf die + // Schleife nicht beenden, sonst landet der Container in einer Restart-Schleife. + fwrite(STDERR, sprintf("[sync] Lauf fehlgeschlagen: %s\n", $e->getMessage())); + } + } + + // Sekundentakt: bestimmt, wie schnell ein Webhook-Trigger bemerkt wird. Der Check ist + // ein file_exists() auf einem lokalen Volume, kostet also praktisch nichts - im + // Gegensatz zum Codeberg-Request, der weiterhin nur bei Bedarf laeuft. + sleep(1); +} diff --git a/api/src/Application.php b/api/src/Application.php index 9976226..e8738e1 100644 --- a/api/src/Application.php +++ b/api/src/Application.php @@ -7,12 +7,14 @@ namespace Monitor\Api; use InvalidArgumentException; use Monitor\Api\Auth\AdminAuthenticator; use Monitor\Api\Auth\AgentAuthenticator; +use Monitor\Api\Auth\WebhookAuthenticator; use Monitor\Api\Controller\AgentController; use Monitor\Api\Controller\EventController; use Monitor\Api\Controller\KpiController; use Monitor\Api\Controller\StreamController; use Monitor\Api\Controller\SystemController; use Monitor\Api\Controller\TicketController; +use Monitor\Api\Controller\WebhookController; use Monitor\Api\Db\Database; use Monitor\Api\Http\MethodNotAllowedException; use Monitor\Api\Http\NotFoundException; @@ -26,6 +28,7 @@ use Monitor\Api\Repository\KpiRepository; use Monitor\Api\Repository\LegacyStatusFileReader; use Monitor\Api\Repository\SystemRepository; use Monitor\Api\Repository\TicketRepository; +use Monitor\Api\Sync\SyncTrigger; use Throwable; /** @@ -38,6 +41,7 @@ final class Application private readonly Router $router; private readonly AgentAuthenticator $agentAuth; private readonly AdminAuthenticator $adminAuth; + private readonly WebhookAuthenticator $webhookAuth; public function __construct(private readonly Database $db, private readonly Config $config) { @@ -49,12 +53,14 @@ final class Application $this->agentAuth = new AgentAuthenticator($agents); $this->adminAuth = new AdminAuthenticator($config->adminApiKey()); + $this->webhookAuth = new WebhookAuthenticator($config->codebergWebhookSecret()); $agentController = new AgentController($agents); $ticketController = new TicketController($tickets); $eventController = new EventController($events); $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath()); $kpiController = new KpiController($kpis); + $webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath())); $streamController = new StreamController( new ChangeFeed($tickets, $events, $systems, $kpis, $agents), $config->sseMaxIterations(), @@ -88,6 +94,15 @@ final class Application // SSE-Push (F13): oeffentlich wie die anderen GET-Endpunkte, liefert nur bereits // oeffentliche Daten (welche Ressourcen sich geaendert haben, keine Inhalte). $this->router->add('GET', '/api/v1/stream', fn (Request $r, array $p) => $streamController->stream($r)); + + // Codeberg-Webhook: kein API-Key, sondern HMAC-Signatur - Codeberg kennt unsere + // Keys nicht, signiert den Body aber mit dem im Webhook hinterlegten Secret. + $this->router->add( + 'POST', + '/api/v1/webhooks/codeberg', + fn (Request $r, array $p) => $webhookController->codeberg($r), + 'webhook' + ); } public function handle(Request $request): Response @@ -112,6 +127,10 @@ final class Application if (!$this->adminAuth->authenticate($request)) { return Response::error('Ungueltiger oder fehlender X-Admin-Key-Header.', 401); } + } elseif ($match->auth === 'webhook') { + if (!$this->webhookAuth->authenticate($request)) { + return Response::error('Ungueltige oder fehlende Webhook-Signatur.', 401); + } } try { diff --git a/api/src/Auth/WebhookAuthenticator.php b/api/src/Auth/WebhookAuthenticator.php new file mode 100644 index 0000000..2cb061a --- /dev/null +++ b/api/src/Auth/WebhookAuthenticator.php @@ -0,0 +1,52 @@ +secret === null || $this->secret === '') { + return false; + } + + $expected = hash_hmac('sha256', $request->rawBody, $this->secret); + + foreach (self::SIGNATURE_HEADERS as $header) { + $provided = $request->header($header); + if ($provided !== null && hash_equals($expected, strtolower(trim($provided)))) { + return true; + } + } + + return false; + } +} diff --git a/api/src/Config.php b/api/src/Config.php index 88b7023..d332f6d 100644 --- a/api/src/Config.php +++ b/api/src/Config.php @@ -69,6 +69,35 @@ final class Config return $this->get('CODEBERG_TOKEN'); } + /** + * Secret des Codeberg-Webhooks (Repo-Einstellungen -> Webhooks). Ohne diesen Wert + * weist POST /api/v1/webhooks/codeberg jede Anfrage ab, siehe Auth\WebhookAuthenticator. + */ + public function codebergWebhookSecret(): ?string + { + return $this->get('CODEBERG_WEBHOOK_SECRET'); + } + + /** + * Signaldatei zwischen Webhook-Endpunkt (api) und Sync-Dauerlaeufer (sync), siehe + * Sync\SyncTrigger. Liegt per Default neben der Datenbank, weil sich beide Container + * genau dieses Verzeichnis teilen. + */ + public function syncTriggerPath(): string + { + return $this->get('MONITOR_SYNC_TRIGGER_FILE', dirname(__DIR__) . '/data/sync-requested'); + } + + /** + * Abstand zwischen zwei periodischen Codeberg-Syncs (bin/sync-codeberg.php --watch). + * Mit funktionierendem Webhook ist das nur noch das Sicherheitsnetz fuer verpasste + * Zustellungen, darf also gross sein. + */ + public function syncIntervalSeconds(): int + { + return max(1, (int) $this->get('MONITOR_SYNC_INTERVAL', '600')); + } + public function deskCount(): int { return (int) $this->get('MONITOR_DESK_COUNT', '8'); diff --git a/api/src/Controller/WebhookController.php b/api/src/Controller/WebhookController.php new file mode 100644 index 0000000..b334c39 --- /dev/null +++ b/api/src/Controller/WebhookController.php @@ -0,0 +1,41 @@ +header('x-github-event') ?? $request->header('x-gitea-event'); + if ($event === 'ping') { + return Response::json(['status' => 'pong']); + } + + $this->trigger->request(); + + // 202: angenommen, aber noch nicht ausgefuehrt - der Sync laeuft asynchron im + // sync-Container. Codeberg braucht nur zu wissen, dass die Zustellung geklappt hat. + return Response::json(['status' => 'sync angefordert'], 202); + } +} diff --git a/api/src/Http/Request.php b/api/src/Http/Request.php index 7193351..d6cf806 100644 --- a/api/src/Http/Request.php +++ b/api/src/Http/Request.php @@ -6,13 +6,20 @@ namespace Monitor\Api\Http; final class Request { - /** @param array $headers */ + /** + * @param array $headers + * @param string $rawBody - der unveraenderte Request-Body. Noetig fuer die + * HMAC-Signaturpruefung des Codeberg-Webhooks (siehe Auth\WebhookAuthenticator): + * die Signatur gilt fuer die exakten Bytes, ein erneut kodiertes $body-Array + * ergaebe eine andere Pruefsumme. + */ private function __construct( public readonly string $method, public readonly string $path, public readonly array $query, public readonly array $body, private readonly array $headers, + public readonly string $rawBody = '', ) { } @@ -47,18 +54,30 @@ final class Request $headers['content-type'] = (string) $_SERVER['CONTENT_TYPE']; } - return new self($method, $path, $query, $body, $headers); + return new self($method, $path, $query, $body, $headers, $rawBody); } - /** @param array $body */ - public static function create(string $method, string $path, array $query = [], array $body = [], array $headers = []): self - { + /** + * @param array $body + * @param string|null $rawBody - nur angeben, wenn der Test genau diese Bytes braucht + * (Signaturpruefung). Sonst wird $body als JSON serialisiert. + */ + public static function create( + string $method, + string $path, + array $query = [], + array $body = [], + array $headers = [], + ?string $rawBody = null, + ): self { $normalized = []; foreach ($headers as $name => $value) { $normalized[strtolower($name)] = $value; } - return new self($method, rtrim($path, '/') ?: '/', $query, $body, $normalized); + $rawBody ??= $body === [] ? '' : (string) json_encode($body); + + return new self($method, rtrim($path, '/') ?: '/', $query, $body, $normalized, $rawBody); } public function header(string $name): ?string diff --git a/api/src/Sync/SyncTrigger.php b/api/src/Sync/SyncTrigger.php new file mode 100644 index 0000000..b3aa425 --- /dev/null +++ b/api/src/Sync/SyncTrigger.php @@ -0,0 +1,50 @@ +path); + if (!is_dir($directory)) { + mkdir($directory, 0o775, true); + } + + touch($this->path); + } + + /** + * Meldet, ob ein Sync angefordert wurde, und quittiert die Anforderung. Das Loeschen vor + * dem eigentlichen Lauf ist Absicht: Eine Anforderung, die waehrend des Laufs eintrifft, + * loest lieber einen weiteren (ueberfluessigen) Sync aus, als verschluckt zu werden. + */ + public function consume(): bool + { + if (!file_exists($this->path)) { + return false; + } + + @unlink($this->path); + + return true; + } +} diff --git a/api/tests/Unit/ApplicationTest.php b/api/tests/Unit/ApplicationTest.php index f986931..c3a7180 100644 --- a/api/tests/Unit/ApplicationTest.php +++ b/api/tests/Unit/ApplicationTest.php @@ -132,4 +132,45 @@ final class ApplicationTest extends TestCase self::assertSame(200, $response->status); self::assertTrue($response->isStream()); } + + /** + * Der Webhook-Endpunkt ist aus dem Internet erreichbar - ohne gueltige Signatur darf er + * nichts ausloesen. Die setUp()-Config setzt kein CODEBERG_WEBHOOK_SECRET, also weist + * er hier grundsaetzlich ab (siehe WebhookAuthenticatorTest fuer die Signaturlogik). + */ + public function testCodebergWebhookWithoutSignatureReturns401(): void + { + $response = $this->app->handle(Request::create( + 'POST', + '/api/v1/webhooks/codeberg', + rawBody: '{"action":"opened"}' + )); + + self::assertSame(401, $response->status); + } + + public function testCodebergWebhookWithValidSignatureIsAccepted(): void + { + $payload = '{"action":"opened","issue":{"number":1}}'; + $triggerPath = sys_get_temp_dir() . '/monitor-app-webhook-' . uniqid() . '/sync-requested'; + + $app = new Application(Database::inMemory(), new Config([ + 'MONITOR_STATUS_FILE' => '/does/not/exist', + 'CODEBERG_WEBHOOK_SECRET' => 'webhook-secret', + 'MONITOR_SYNC_TRIGGER_FILE' => $triggerPath, + ])); + + $response = $app->handle(Request::create( + 'POST', + '/api/v1/webhooks/codeberg', + headers: ['X-Forgejo-Signature' => hash_hmac('sha256', $payload, 'webhook-secret')], + rawBody: $payload + )); + + self::assertSame(202, $response->status); + self::assertFileExists($triggerPath); + + unlink($triggerPath); + rmdir(dirname($triggerPath)); + } } diff --git a/api/tests/Unit/Auth/WebhookAuthenticatorTest.php b/api/tests/Unit/Auth/WebhookAuthenticatorTest.php new file mode 100644 index 0000000..35e2bba --- /dev/null +++ b/api/tests/Unit/Auth/WebhookAuthenticatorTest.php @@ -0,0 +1,98 @@ +authenticate($this->requestWithSignature( + 'X-Forgejo-Signature', + hash_hmac('sha256', self::PAYLOAD, self::SECRET) + ))); + } + + public function testAcceptsGiteaSignatureHeaderForCompatibility(): void + { + $auth = new WebhookAuthenticator(self::SECRET); + + self::assertTrue($auth->authenticate($this->requestWithSignature( + 'X-Gitea-Signature', + hash_hmac('sha256', self::PAYLOAD, self::SECRET) + ))); + } + + public function testRejectsSignatureCreatedWithAnotherSecret(): void + { + $auth = new WebhookAuthenticator(self::SECRET); + + self::assertFalse($auth->authenticate($this->requestWithSignature( + 'X-Forgejo-Signature', + hash_hmac('sha256', self::PAYLOAD, 'falsches-secret') + ))); + } + + /** + * Die Signatur muss ueber den rohen Body gebildet werden: ein Angreifer darf den Payload + * nicht veraendern koennen, ohne dass die Pruefsumme bricht. + */ + public function testRejectsSignatureForDifferentPayload(): void + { + $auth = new WebhookAuthenticator(self::SECRET); + + $request = Request::create( + 'POST', + '/api/v1/webhooks/codeberg', + [], + [], + ['X-Forgejo-Signature' => hash_hmac('sha256', self::PAYLOAD, self::SECRET)], + '{"action":"closed","issue":{"number":7}}' + ); + + self::assertFalse($auth->authenticate($request)); + } + + public function testRejectsRequestWithoutSignature(): void + { + $auth = new WebhookAuthenticator(self::SECRET); + + $request = Request::create('POST', '/api/v1/webhooks/codeberg', [], [], [], self::PAYLOAD); + + self::assertFalse($auth->authenticate($request)); + } + + public function testRejectsEverythingWhenNoSecretIsConfigured(): void + { + foreach ([null, ''] as $secret) { + $auth = new WebhookAuthenticator($secret); + + self::assertFalse($auth->authenticate($this->requestWithSignature( + 'X-Forgejo-Signature', + hash_hmac('sha256', self::PAYLOAD, (string) $secret) + ))); + } + } + + private function requestWithSignature(string $header, string $signature): Request + { + return Request::create( + 'POST', + '/api/v1/webhooks/codeberg', + [], + [], + [$header => $signature], + self::PAYLOAD + ); + } +} diff --git a/api/tests/Unit/Controller/WebhookControllerTest.php b/api/tests/Unit/Controller/WebhookControllerTest.php new file mode 100644 index 0000000..eaa6b6f --- /dev/null +++ b/api/tests/Unit/Controller/WebhookControllerTest.php @@ -0,0 +1,79 @@ +path = sys_get_temp_dir() . '/monitor-webhook-' . uniqid() . '/sync-requested'; + $this->trigger = new SyncTrigger($this->path); + } + + protected function tearDown(): void + { + if (file_exists($this->path)) { + unlink($this->path); + } + if (is_dir(dirname($this->path))) { + rmdir(dirname($this->path)); + } + } + + public function testIssueEventRequestsASync(): void + { + $controller = new WebhookController($this->trigger); + + $response = $controller->codeberg($this->request('issues')); + + self::assertSame(202, $response->status); + self::assertTrue($this->trigger->consume()); + } + + /** + * Codeberg schickt beim Anlegen des Webhooks ein "ping" zum Testen der Zustellung - das + * ist keine Issue-Aenderung und soll keinen Codeberg-Request ausloesen. + */ + public function testPingIsAnsweredWithoutRequestingASync(): void + { + $controller = new WebhookController($this->trigger); + + $response = $controller->codeberg($this->request('ping')); + + self::assertSame(200, $response->status); + self::assertFalse($this->trigger->consume()); + } + + /** Unbekannte Events loesen einen Sync aus - lieber einmal zu viel als einen verpassten. */ + public function testUnknownEventStillRequestsASync(): void + { + $controller = new WebhookController($this->trigger); + + $response = $controller->codeberg($this->request('issue_label')); + + self::assertSame(202, $response->status); + self::assertTrue($this->trigger->consume()); + } + + private function request(string $event): Request + { + return Request::create( + 'POST', + '/api/v1/webhooks/codeberg', + [], + [], + ['X-Gitea-Event' => $event], + '{}' + ); + } +} diff --git a/api/tests/Unit/Sync/SyncTriggerTest.php b/api/tests/Unit/Sync/SyncTriggerTest.php new file mode 100644 index 0000000..bad9a5e --- /dev/null +++ b/api/tests/Unit/Sync/SyncTriggerTest.php @@ -0,0 +1,62 @@ +path = sys_get_temp_dir() . '/monitor-sync-trigger-' . uniqid() . '/sync-requested'; + } + + protected function tearDown(): void + { + if (file_exists($this->path)) { + unlink($this->path); + } + if (is_dir(dirname($this->path))) { + rmdir(dirname($this->path)); + } + } + + public function testConsumeReportsNothingWhenNoSyncWasRequested(): void + { + self::assertFalse((new SyncTrigger($this->path))->consume()); + } + + public function testConsumeReportsRequestedSyncExactlyOnce(): void + { + $trigger = new SyncTrigger($this->path); + $trigger->request(); + + self::assertTrue($trigger->consume()); + self::assertFalse($trigger->consume()); + } + + /** Mehrere Webhooks vor dem naechsten Lauf duerfen nicht zu mehreren Syncs fuehren. */ + public function testRepeatedRequestsCollapseIntoOneSync(): void + { + $trigger = new SyncTrigger($this->path); + $trigger->request(); + $trigger->request(); + $trigger->request(); + + self::assertTrue($trigger->consume()); + self::assertFalse($trigger->consume()); + } + + /** api- und sync-Container teilen sich das Volume, legen die Datei aber ggf. selbst an. */ + public function testRequestCreatesMissingDirectory(): void + { + (new SyncTrigger($this->path))->request(); + + self::assertFileExists($this->path); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index e31a514..e91d02c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,17 +20,28 @@ services: MONITOR_DESK_COUNT: 8 MONITOR_SSE_MAX_ITERATIONS: 30 MONITOR_SSE_POLL_INTERVAL: 2 + MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested CODEBERG_API_BASE: https://codeberg.org/api/v1 CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610} CODEBERG_REPO: ${CODEBERG_REPO:-monitor} CODEBERG_TOKEN: ${CODEBERG_TOKEN:-} + # Secret des Codeberg-Webhooks. Ohne diesen Wert weist POST /api/v1/webhooks/codeberg + # jede Anfrage mit 401 ab - der Endpunkt ist aus dem Internet erreichbar und die + # Signatur ist sein einziger Schutz. + CODEBERG_WEBHOOK_SECRET: ${CODEBERG_WEBHOOK_SECRET:-} # Haelt Backlog/Done aus Codeberg aktuell (F4, siehe API.md Abschnitt "Codeberg-Sync"): - # laeuft dasselbe Image wie api, teilt sich ueber das ./api-Volume dieselbe SQLite-Datei - # und ruft bin/sync-codeberg.php in einer Endlosschleife auf. Ohne diesen Service bleibt - # das Board leer, solange kein Agent selbst Tickets meldet. - # Rein ausgehende Verbindungen (nach codeberg.org), kein Port-Mapping - in Produktion sind - # also weder Proxy- noch Firewall-Freigaben noetig. + # laeuft dasselbe Image wie api und teilt sich ueber das ./api-Volume dieselbe SQLite-Datei. + # Ohne diesen Service bleibt das Board leer, solange kein Agent selbst Tickets meldet. + # + # Der Watch-Modus laeuft aus zwei Gruenden: periodisch alle MONITOR_SYNC_INTERVAL Sekunden + # und sofort, wenn der api-Container ueber die Trigger-Datei einen Codeberg-Webhook + # weiterreicht (siehe Sync\SyncTrigger). Mit funktionierendem Webhook ist das Intervall nur + # noch das Sicherheitsnetz fuer verpasste Zustellungen und darf deshalb gross sein. + # + # Dieser Service selbst baut weiterhin nur ausgehende Verbindungen auf und braucht kein + # Port-Mapping. Die eingehende Freigabe betrifft ausschliesslich den api-Service, ueber den + # Codeberg den Webhook zustellt. sync: build: context: ./api @@ -44,21 +55,12 @@ services: environment: MONITOR_DB_PATH: /app/data/monitor.sqlite MONITOR_SYNC_INTERVAL: ${MONITOR_SYNC_INTERVAL:-600} + MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested CODEBERG_API_BASE: https://codeberg.org/api/v1 CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610} CODEBERG_REPO: ${CODEBERG_REPO:-monitor} CODEBERG_TOKEN: ${CODEBERG_TOKEN:-} - # "$$" ist ein maskiertes "$" - die Variable soll die Shell im Container aufloesen, nicht - # Compose. Ein fehlgeschlagener Lauf (Codeberg nicht erreichbar, Rate-Limit) darf die - # Schleife nicht beenden, sonst landet der Service in einer Restart-Schleife. - command: - - sh - - -c - - | - while true; do - php bin/sync-codeberg.php || echo "[sync] Lauf fehlgeschlagen, naechster Versuch in $${MONITOR_SYNC_INTERVAL}s" - sleep "$$MONITOR_SYNC_INTERVAL" - done + command: php bin/sync-codeberg.php --watch # Statisches Frontend (html/) - Phase 2 der Neuausrichtung (Buero-Visualisierung). # Nur zur lokalen Ansicht/Entwicklung, kein Build-Schritt noetig (Vanilla HTML/CSS/JS).