Merge pull request 'Codeberg-Webhook fuer Sofort-Sync statt Warten auf den Polling-Lauf' (#23) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/23
This commit is contained in:
fds2610 2026-07-29 21:08:29 +02:00
commit cddba1ee8f
15 changed files with 610 additions and 31 deletions

View file

@ -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
# <oeffentliche-api-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=

48
API.md
View file

@ -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://<oeffentliche-api-url>/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:

View file

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

View file

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

View file

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

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Auth;
use Monitor\Api\Http\Request;
/**
* Prueft die Signatur eingehender Codeberg-Webhooks (POST /api/v1/webhooks/codeberg).
*
* Anders als die uebrigen schreibenden Endpunkte kann dieser keinen API-Key verlangen -
* Codeberg schickt stattdessen einen HMAC-SHA256 (hex) ueber den unveraenderten
* Request-Body, gebildet mit dem beim Webhook hinterlegten Secret. Der Endpunkt ist aus
* dem Internet erreichbar, die Signatur ist also der einzige Schutz davor, dass Fremde
* beliebig oft einen Codeberg-Sync ausloesen.
*/
final class WebhookAuthenticator
{
/**
* Forgejo sendet X-Forgejo-Signature, aus Gitea-Kompatibilitaet zusaetzlich
* X-Gitea-Signature mit identischem Wert. Wir akzeptieren beide, damit die Pruefung
* unabhaengig davon funktioniert, welche Header-Variante die Codeberg-Instanz
* kuenftig noch mitschickt.
*/
private const SIGNATURE_HEADERS = ['x-forgejo-signature', 'x-gitea-signature'];
public function __construct(private readonly ?string $secret)
{
}
public function authenticate(Request $request): bool
{
// Ohne konfiguriertes Secret bleibt der Endpunkt geschlossen. Ein Fallback auf
// "keine Pruefung" waere hier besonders gefaehrlich, weil ein vergessenes Secret
// in Produktion sonst unbemerkt einen offenen Endpunkt hinterliesse.
if ($this->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;
}
}

View file

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

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Controller;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Sync\SyncTrigger;
/**
* Nimmt Codeberg-Webhooks entgegen, damit Aenderungen an Issues nicht erst beim naechsten
* Polling-Lauf des sync-Containers auf dem Board landen (Issue: "Aenderungen erst nach dem
* Pollen sichtbar").
*
* Der Payload wird bewusst nicht ausgewertet - er dient nur als Ausloeser fuer einen
* regulaeren Sync. So bleibt CodebergSyncService die einzige Stelle, die Codeberg-Issues auf
* Tickets abbildet, und ein verpasster Webhook ist folgenlos: der periodische Sync holt
* denselben Stand ohnehin nach.
*/
final class WebhookController
{
public function __construct(private readonly SyncTrigger $trigger)
{
}
public function codeberg(Request $request): Response
{
// "ping" schickt Codeberg beim Anlegen des Webhooks zum Testen der Zustellung.
$event = $request->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);
}
}

View file

@ -6,13 +6,20 @@ namespace Monitor\Api\Http;
final class Request
{
/** @param array<string, string> $headers */
/**
* @param array<string, string> $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<string, mixed> $body */
public static function create(string $method, string $path, array $query = [], array $body = [], array $headers = []): self
{
/**
* @param array<string, mixed> $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

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Sync;
/**
* Verbindet den Webhook-Endpunkt (api-Container) mit dem Sync-Dauerlaeufer (sync-Container),
* ohne dass die beiden direkt miteinander sprechen muessen: Der Webhook legt eine leere
* Trigger-Datei an, der Sync-Prozess sieht sie beim naechsten Sekunden-Tick und laeuft sofort.
*
* Warum nicht direkt im Webhook synchronisieren? Forgejo bricht die Zustellung nach wenigen
* Sekunden ab (DELIVER_TIMEOUT, Default 5s) und markiert sie dann als fehlgeschlagen. Ein
* Codeberg-Sync macht mehrere HTTP-Requests und kann laenger dauern - der Endpunkt muss also
* sofort antworten. Die gemeinsame Datei genuegt als Signalweg, weil sich beide Container
* ohnehin dasselbe ./api-Volume teilen (siehe docker-compose.yml).
*/
final class SyncTrigger
{
public function __construct(private readonly string $path)
{
}
/** Fordert einen Sync an. Mehrfaches Anfordern vor dem naechsten Lauf ist ein Sync. */
public function request(): void
{
$directory = dirname($this->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;
}
}

View file

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

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Auth;
use Monitor\Api\Auth\WebhookAuthenticator;
use Monitor\Api\Http\Request;
use PHPUnit\Framework\TestCase;
final class WebhookAuthenticatorTest extends TestCase
{
private const SECRET = 'geheim';
private const PAYLOAD = '{"action":"opened","issue":{"number":7}}';
public function testAcceptsForgejoSignature(): void
{
$auth = new WebhookAuthenticator(self::SECRET);
self::assertTrue($auth->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
);
}
}

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Controller;
use Monitor\Api\Controller\WebhookController;
use Monitor\Api\Http\Request;
use Monitor\Api\Sync\SyncTrigger;
use PHPUnit\Framework\TestCase;
final class WebhookControllerTest extends TestCase
{
private string $path;
private SyncTrigger $trigger;
protected function setUp(): void
{
$this->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],
'{}'
);
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Sync;
use Monitor\Api\Sync\SyncTrigger;
use PHPUnit\Framework\TestCase;
final class SyncTriggerTest extends TestCase
{
private string $path;
protected function setUp(): void
{
$this->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);
}
}

View file

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