$payload['project_slug'] ?? 'Unbekannt', 'Umgebung' => $payload['environment'] ?? 'production', 'Item-ID' => $payload['item_id'] ?? 'neu', ] ); } elseif ($event === 'monitor.down') { RocketChatNotifier::sendAlert( $db, 'System-Ausfall erkannt (Watchdog)', (string)($payload['source'] ?? 'Monitor') . ' (' . ($payload['instance'] ?? 'default') . '): ' . ($payload['reason'] ?? 'Heartbeat ausgeblieben'), 'alarm', [ 'Quelle' => $payload['source'] ?? 'unbekannt', 'Instanz' => $payload['instance'] ?? 'default', 'Von Zustand' => $payload['from_state'] ?? 'up', 'Nach Zustand' => $payload['to_state'] ?? 'down', ] ); } elseif ($event === 'security.lockout') { RocketChatNotifier::sendAlert( $db, 'Benutzerkonto vorübergehend gesperrt', sprintf( 'Das Konto "%s" wurde nach wiederholten Fehlversuchen von IP %s für %d Minute(n) gesperrt.', $payload['username'] ?? 'unbekannt', $payload['ip'] ?? 'unbekannt', (int)($payload['duration_minutes'] ?? 15) ), 'alarm', [ 'Benutzer' => $payload['username'] ?? 'unbekannt', 'IP-Adresse' => $payload['ip'] ?? 'unbekannt', 'Sperrdauer' => ($payload['duration_minutes'] ?? 15) . ' Min', 'Zeitpunkt' => gmdate('Y-m-d H:i:s') . ' UTC', ] ); } elseif ($event === 'security.attack') { RocketChatNotifier::sendAlert( $db, 'Sicherheitsalarm: Brute-Force-Angriff erkannt', sprintf( 'Verdächtige Aktivität von IP %s: %s', $payload['ip'] ?? 'unbekannt', $payload['details'] ?? 'Wiederholte Fehlversuche' ), 'alarm', [ 'IP-Adresse' => $payload['ip'] ?? 'unbekannt', 'Ziel / Pfad' => $payload['target'] ?? 'Login / API', 'Typ' => $payload['type'] ?? 'Brute-Force', 'Zeitpunkt' => gmdate('Y-m-d H:i:s') . ' UTC', ] ); } if (self::$dispatchedThisRequest >= self::MAX_TARGETS) { return; } $targets = self::targetsFor($db, $event, $payload['project_slug'] ?? null); if ($targets === []) { return; } $body = [ 'event' => $event, 'timestamp' => gmdate('c'), 'source' => (string)Config::get('app.url', ''), 'data' => $payload, ]; $json = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($json === false) { Logger::warning('Webhook-Payload nicht kodierbar', ['event' => $event]); return; } foreach ($targets as $target) { if (self::$dispatchedThisRequest >= self::MAX_TARGETS) { break; } self::$dispatchedThisRequest++; self::deliver($db, $target, $json); } } /** * @return list> */ private static function targetsFor(PDO $db, string $event, $projectSlug): array { try { $stmt = $db->prepare(' SELECT id, name, url, secret, events, project_slug FROM dc_webhooks WHERE enabled = 1 AND (project_slug IS NULL OR project_slug = :slug) LIMIT 25 '); $stmt->execute([':slug' => is_string($projectSlug) ? $projectSlug : '']); $rows = $stmt->fetchAll() ?: []; } catch (\Throwable $e) { // Tabelle fehlt (Migration noch nicht gelaufen) - kein Grund zu scheitern. return []; } $matching = []; foreach ($rows as $row) { $subscribed = array_map('trim', explode(',', (string)$row['events'])); if (in_array($event, $subscribed, true) || in_array('*', $subscribed, true)) { $matching[] = $row; } } return $matching; } private static function deliver(PDO $db, array $target, string $json): void { $secret = (string)($target['secret'] ?? ''); if ($secret === '') { $secret = (string)Config::get('security.webhook_key', ''); } $timestamp = (string)time(); $signature = $secret !== '' ? hash_hmac('sha256', $timestamp . '.' . $json, $secret) : ''; $headers = [ 'Content-Type: application/json', 'User-Agent: Deploymentcenter-Webhook/1.0', 'X-DC-Timestamp: ' . $timestamp, ]; if ($signature !== '') { $headers[] = 'X-DC-Signature: sha256=' . $signature; } [$ok, $status, $error] = self::post((string)$target['url'], $json, $headers); self::recordResult($db, (int)$target['id'], $ok, $status, $error); } /** * @param list $headers * @return array{0:bool,1:int,2:?string} */ private static function post(string $url, string $json, array $headers): array { if (!preg_match('#^https?://#i', $url)) { return [false, 0, 'Ungueltige URL']; } if (function_exists('curl_init')) { $ch = curl_init($url); if ($ch === false) { return [false, 0, 'curl_init fehlgeschlagen']; } curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $json, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => self::TIMEOUT_SECONDS, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_FOLLOWLOCATION => false, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); $response = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $error = $response === false ? curl_error($ch) : null; curl_close($ch); return [$status >= 200 && $status < 300, $status, $error]; } $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $json, 'timeout' => self::TIMEOUT_SECONDS, 'ignore_errors' => true, ], ]); $result = @file_get_contents($url, false, $context); $status = 0; if (isset($http_response_header[0]) && preg_match('#\s(\d{3})\s#', $http_response_header[0], $m) === 1) { $status = (int)$m[1]; } return [ $result !== false && $status >= 200 && $status < 300, $status, $result === false ? 'Anfrage fehlgeschlagen' : null, ]; } private static function recordResult(PDO $db, int $webhookId, bool $ok, int $status, ?string $error): void { try { $stmt = $db->prepare(' UPDATE dc_webhooks SET last_status = :status, last_error = :error, last_fired_at = UTC_TIMESTAMP(), failure_count = IF(:ok = 1, 0, failure_count + 1), enabled = IF(:ok2 = 1, enabled, IF(failure_count + 1 >= 20, 0, enabled)) WHERE id = :id '); $stmt->execute([ ':status' => $ok ? 'ok (' . $status . ')' : 'failed (' . $status . ')', ':error' => $error !== null ? mb_substr($error, 0, 500) : null, ':ok' => $ok ? 1 : 0, ':ok2' => $ok ? 1 : 0, ':id' => $webhookId, ]); } catch (\Throwable $e) { Logger::warning('Webhook-Ergebnis nicht gespeichert', ['id' => $webhookId]); } } }