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 <noreply@anthropic.com>
This commit is contained in:
parent
f0018976bf
commit
3efa0ee423
15 changed files with 610 additions and 31 deletions
13
.env.example
13
.env.example
|
|
@ -18,9 +18,20 @@ CODEBERG_REPO=monitor
|
||||||
# MONITOR_SYNC_INTERVAL (haeufigem Sync) empfehlenswert
|
# MONITOR_SYNC_INTERVAL (haeufigem Sync) empfehlenswert
|
||||||
CODEBERG_TOKEN=
|
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.
|
# 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
|
# 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,
|
# (Service "web", siehe docker/nginx/default.conf.template) - der Countdown zaehlt nur mit,
|
||||||
# er loest selbst keinen echten Reload/Sync aus.
|
# er loest selbst keinen echten Reload/Sync aus.
|
||||||
MONITOR_SYNC_INTERVAL=600
|
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
48
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-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 |
|
| `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.
|
Der Klartext-API-Key eines Agenten wird **nur einmal** bei der Registrierung ausgegeben.
|
||||||
Gespeichert wird ausschliesslich ein SHA-256-Hash in SQLite (Tabelle `agents`,
|
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
|
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
|
Ausgefuehrt wird das Skript vom Compose-Service `sync` (siehe
|
||||||
[docker-compose.yml](docker-compose.yml)) - kein Host-Cron noetig. Der Service startet mit
|
[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
|
dem Stack (`--watch`), macht sofort einen ersten Lauf und wiederholt ihn danach aus zwei
|
||||||
`MONITOR_SYNC_INTERVAL` Sekunden (Default 600, siehe `.env.example`). Er nutzt dasselbe
|
Gruenden: periodisch alle `MONITOR_SYNC_INTERVAL` Sekunden (Default 600, siehe
|
||||||
Image und dieselbe SQLite-Datei wie `api` und verbindet sich ausschliesslich ausgehend nach
|
`.env.example`) und **sofort**, sobald ein Codeberg-Webhook eintrifft (siehe unten). Er nutzt
|
||||||
codeberg.org - es ist also kein Port-Mapping und keine Firewall-Freigabe noetig.
|
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
|
```bash
|
||||||
docker compose up -d sync # Sync-Service starten
|
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
|
erreichbar: …` bzw. `Codeberg-API-Fehler (HTTP 4xx)`) - der erste Anlaufpunkt, wenn Backlog
|
||||||
und Done leer bleiben.
|
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
|
## Fehlerformat
|
||||||
|
|
||||||
Alle Fehlerantworten haben die Form `{ "error": "‹Meldung›" }` mit passendem HTTP-Status:
|
Alle Fehlerantworten haben die Form `{ "error": "‹Meldung›" }` mit passendem HTTP-Status:
|
||||||
|
|
|
||||||
|
|
@ -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!".
|
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`).
|
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
|
- 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
|
aus den Issues des konfigurierten Codeberg-Repos — periodisch alle `MONITOR_SYNC_INTERVAL`
|
||||||
(siehe [API.md](API.md) Abschnitt „Codeberg-Sync")
|
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
|
- 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
|
eine Datenquelle für den System-Monitor; die alte tabellarische Statusseite ist mit
|
||||||
Phase 7 entfallen (F16), `status.txt` speist weiterhin den System-Monitor
|
Phase 7 entfallen (F16), `status.txt` speist weiterhin den System-Monitor
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use Monitor\Api\Db\Database;
|
||||||
use Monitor\Api\Repository\TicketRepository;
|
use Monitor\Api\Repository\TicketRepository;
|
||||||
use Monitor\Api\Sync\CodebergSyncService;
|
use Monitor\Api\Sync\CodebergSyncService;
|
||||||
use Monitor\Api\Sync\CurlCodebergClient;
|
use Monitor\Api\Sync\CurlCodebergClient;
|
||||||
|
use Monitor\Api\Sync\SyncTrigger;
|
||||||
|
|
||||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
|
@ -21,6 +22,38 @@ $service = new CodebergSyncService(
|
||||||
$config->codebergRepo(),
|
$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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@ namespace Monitor\Api;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Monitor\Api\Auth\AdminAuthenticator;
|
use Monitor\Api\Auth\AdminAuthenticator;
|
||||||
use Monitor\Api\Auth\AgentAuthenticator;
|
use Monitor\Api\Auth\AgentAuthenticator;
|
||||||
|
use Monitor\Api\Auth\WebhookAuthenticator;
|
||||||
use Monitor\Api\Controller\AgentController;
|
use Monitor\Api\Controller\AgentController;
|
||||||
use Monitor\Api\Controller\EventController;
|
use Monitor\Api\Controller\EventController;
|
||||||
use Monitor\Api\Controller\KpiController;
|
use Monitor\Api\Controller\KpiController;
|
||||||
use Monitor\Api\Controller\StreamController;
|
use Monitor\Api\Controller\StreamController;
|
||||||
use Monitor\Api\Controller\SystemController;
|
use Monitor\Api\Controller\SystemController;
|
||||||
use Monitor\Api\Controller\TicketController;
|
use Monitor\Api\Controller\TicketController;
|
||||||
|
use Monitor\Api\Controller\WebhookController;
|
||||||
use Monitor\Api\Db\Database;
|
use Monitor\Api\Db\Database;
|
||||||
use Monitor\Api\Http\MethodNotAllowedException;
|
use Monitor\Api\Http\MethodNotAllowedException;
|
||||||
use Monitor\Api\Http\NotFoundException;
|
use Monitor\Api\Http\NotFoundException;
|
||||||
|
|
@ -26,6 +28,7 @@ use Monitor\Api\Repository\KpiRepository;
|
||||||
use Monitor\Api\Repository\LegacyStatusFileReader;
|
use Monitor\Api\Repository\LegacyStatusFileReader;
|
||||||
use Monitor\Api\Repository\SystemRepository;
|
use Monitor\Api\Repository\SystemRepository;
|
||||||
use Monitor\Api\Repository\TicketRepository;
|
use Monitor\Api\Repository\TicketRepository;
|
||||||
|
use Monitor\Api\Sync\SyncTrigger;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -38,6 +41,7 @@ final class Application
|
||||||
private readonly Router $router;
|
private readonly Router $router;
|
||||||
private readonly AgentAuthenticator $agentAuth;
|
private readonly AgentAuthenticator $agentAuth;
|
||||||
private readonly AdminAuthenticator $adminAuth;
|
private readonly AdminAuthenticator $adminAuth;
|
||||||
|
private readonly WebhookAuthenticator $webhookAuth;
|
||||||
|
|
||||||
public function __construct(private readonly Database $db, private readonly Config $config)
|
public function __construct(private readonly Database $db, private readonly Config $config)
|
||||||
{
|
{
|
||||||
|
|
@ -49,12 +53,14 @@ final class Application
|
||||||
|
|
||||||
$this->agentAuth = new AgentAuthenticator($agents);
|
$this->agentAuth = new AgentAuthenticator($agents);
|
||||||
$this->adminAuth = new AdminAuthenticator($config->adminApiKey());
|
$this->adminAuth = new AdminAuthenticator($config->adminApiKey());
|
||||||
|
$this->webhookAuth = new WebhookAuthenticator($config->codebergWebhookSecret());
|
||||||
|
|
||||||
$agentController = new AgentController($agents);
|
$agentController = new AgentController($agents);
|
||||||
$ticketController = new TicketController($tickets);
|
$ticketController = new TicketController($tickets);
|
||||||
$eventController = new EventController($events);
|
$eventController = new EventController($events);
|
||||||
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
|
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
|
||||||
$kpiController = new KpiController($kpis);
|
$kpiController = new KpiController($kpis);
|
||||||
|
$webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath()));
|
||||||
$streamController = new StreamController(
|
$streamController = new StreamController(
|
||||||
new ChangeFeed($tickets, $events, $systems, $kpis, $agents),
|
new ChangeFeed($tickets, $events, $systems, $kpis, $agents),
|
||||||
$config->sseMaxIterations(),
|
$config->sseMaxIterations(),
|
||||||
|
|
@ -88,6 +94,15 @@ final class Application
|
||||||
// SSE-Push (F13): oeffentlich wie die anderen GET-Endpunkte, liefert nur bereits
|
// SSE-Push (F13): oeffentlich wie die anderen GET-Endpunkte, liefert nur bereits
|
||||||
// oeffentliche Daten (welche Ressourcen sich geaendert haben, keine Inhalte).
|
// oeffentliche Daten (welche Ressourcen sich geaendert haben, keine Inhalte).
|
||||||
$this->router->add('GET', '/api/v1/stream', fn (Request $r, array $p) => $streamController->stream($r));
|
$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
|
public function handle(Request $request): Response
|
||||||
|
|
@ -112,6 +127,10 @@ final class Application
|
||||||
if (!$this->adminAuth->authenticate($request)) {
|
if (!$this->adminAuth->authenticate($request)) {
|
||||||
return Response::error('Ungueltiger oder fehlender X-Admin-Key-Header.', 401);
|
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 {
|
try {
|
||||||
|
|
|
||||||
52
api/src/Auth/WebhookAuthenticator.php
Normal file
52
api/src/Auth/WebhookAuthenticator.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -69,6 +69,35 @@ final class Config
|
||||||
return $this->get('CODEBERG_TOKEN');
|
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
|
public function deskCount(): int
|
||||||
{
|
{
|
||||||
return (int) $this->get('MONITOR_DESK_COUNT', '8');
|
return (int) $this->get('MONITOR_DESK_COUNT', '8');
|
||||||
|
|
|
||||||
41
api/src/Controller/WebhookController.php
Normal file
41
api/src/Controller/WebhookController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,13 +6,20 @@ namespace Monitor\Api\Http;
|
||||||
|
|
||||||
final class Request
|
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(
|
private function __construct(
|
||||||
public readonly string $method,
|
public readonly string $method,
|
||||||
public readonly string $path,
|
public readonly string $path,
|
||||||
public readonly array $query,
|
public readonly array $query,
|
||||||
public readonly array $body,
|
public readonly array $body,
|
||||||
private readonly array $headers,
|
private readonly array $headers,
|
||||||
|
public readonly string $rawBody = '',
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,18 +54,30 @@ final class Request
|
||||||
$headers['content-type'] = (string) $_SERVER['CONTENT_TYPE'];
|
$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 = [];
|
$normalized = [];
|
||||||
foreach ($headers as $name => $value) {
|
foreach ($headers as $name => $value) {
|
||||||
$normalized[strtolower($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
|
public function header(string $name): ?string
|
||||||
|
|
|
||||||
50
api/src/Sync/SyncTrigger.php
Normal file
50
api/src/Sync/SyncTrigger.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -132,4 +132,45 @@ final class ApplicationTest extends TestCase
|
||||||
self::assertSame(200, $response->status);
|
self::assertSame(200, $response->status);
|
||||||
self::assertTrue($response->isStream());
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
98
api/tests/Unit/Auth/WebhookAuthenticatorTest.php
Normal file
98
api/tests/Unit/Auth/WebhookAuthenticatorTest.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
api/tests/Unit/Controller/WebhookControllerTest.php
Normal file
79
api/tests/Unit/Controller/WebhookControllerTest.php
Normal 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],
|
||||||
|
'{}'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
api/tests/Unit/Sync/SyncTriggerTest.php
Normal file
62
api/tests/Unit/Sync/SyncTriggerTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,17 +20,28 @@ services:
|
||||||
MONITOR_DESK_COUNT: 8
|
MONITOR_DESK_COUNT: 8
|
||||||
MONITOR_SSE_MAX_ITERATIONS: 30
|
MONITOR_SSE_MAX_ITERATIONS: 30
|
||||||
MONITOR_SSE_POLL_INTERVAL: 2
|
MONITOR_SSE_POLL_INTERVAL: 2
|
||||||
|
MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested
|
||||||
CODEBERG_API_BASE: https://codeberg.org/api/v1
|
CODEBERG_API_BASE: https://codeberg.org/api/v1
|
||||||
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
|
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
|
||||||
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
|
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
|
||||||
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}
|
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"):
|
# 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
|
# laeuft dasselbe Image wie api und teilt sich ueber das ./api-Volume dieselbe SQLite-Datei.
|
||||||
# und ruft bin/sync-codeberg.php in einer Endlosschleife auf. Ohne diesen Service bleibt
|
# Ohne diesen Service bleibt das Board leer, solange kein Agent selbst Tickets meldet.
|
||||||
# das Board leer, solange kein Agent selbst Tickets meldet.
|
#
|
||||||
# Rein ausgehende Verbindungen (nach codeberg.org), kein Port-Mapping - in Produktion sind
|
# Der Watch-Modus laeuft aus zwei Gruenden: periodisch alle MONITOR_SYNC_INTERVAL Sekunden
|
||||||
# also weder Proxy- noch Firewall-Freigaben noetig.
|
# 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:
|
sync:
|
||||||
build:
|
build:
|
||||||
context: ./api
|
context: ./api
|
||||||
|
|
@ -44,21 +55,12 @@ services:
|
||||||
environment:
|
environment:
|
||||||
MONITOR_DB_PATH: /app/data/monitor.sqlite
|
MONITOR_DB_PATH: /app/data/monitor.sqlite
|
||||||
MONITOR_SYNC_INTERVAL: ${MONITOR_SYNC_INTERVAL:-600}
|
MONITOR_SYNC_INTERVAL: ${MONITOR_SYNC_INTERVAL:-600}
|
||||||
|
MONITOR_SYNC_TRIGGER_FILE: /app/data/sync-requested
|
||||||
CODEBERG_API_BASE: https://codeberg.org/api/v1
|
CODEBERG_API_BASE: https://codeberg.org/api/v1
|
||||||
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
|
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
|
||||||
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
|
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
|
||||||
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}
|
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}
|
||||||
# "$$" ist ein maskiertes "$" - die Variable soll die Shell im Container aufloesen, nicht
|
command: php bin/sync-codeberg.php --watch
|
||||||
# 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
|
|
||||||
|
|
||||||
# Statisches Frontend (html/) - Phase 2 der Neuausrichtung (Buero-Visualisierung).
|
# Statisches Frontend (html/) - Phase 2 der Neuausrichtung (Buero-Visualisierung).
|
||||||
# Nur zur lokalen Ansicht/Entwicklung, kein Build-Schritt noetig (Vanilla HTML/CSS/JS).
|
# Nur zur lokalen Ansicht/Entwicklung, kein Build-Schritt noetig (Vanilla HTML/CSS/JS).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue