Files
domnitor/app/Services/Channels/WebhookChannel.php
Hosteroid a265a58456 Enhance DNS discovery, validation & transfers
Add comprehensive DNS management and input validation, plus safer transfer and logging behavior.

- Add CronHelper utilities for cron scripts and unify logging/formatting.
- Improve InputValidator: sanitizeDomainInput and validateRootDomain (handles multi-level TLDs) and use throughout domain import/create flows to reject subdomains.
- DomainController: refactor DNS refresh to support quick/deep discovery (background deep scans), add endpoints to discover, add/delete/bulk-delete DNS records, import BIND zone files, enrich IP metadata via enrichIpDetails, and strengthen bulk import/reporting messages.
- DnsRecord model: add source column handling (discovered/manual/imported), avoid auto-deleting manual/imported records, and add helpers for deleting, bulk deleting, manual adding and importing zone records.
- Tag, NotificationGroup and Domain transfer logic: unlink groups when ownership changes, remove tags that belong to other users, add audit logging via Logger and improved bulk transfer reporting. TagController/View: show transferable users for admins and skip global tags on transfer.
- Notification channels (Discord, Mattermost, etc.) and EmailHelper: allow explicit subjects and improve payload fields based on notification type.
- Add new migration 029_add_dns_record_source.sql and wire it into the installer; update migrations detection.
- Add new views/partials for confirm/import/transfer modals, update various domain/group/tag templates, and update cron scripts and routes for discovery.

These changes preserve manual/imported DNS records, improve root-domain validation, enable background deep discovery, and add better logging/audit trails for transfers and imports.
2026-03-10 22:54:28 +02:00

266 lines
9.7 KiB
PHP

<?php
namespace App\Services\Channels;
use GuzzleHttp\Client;
use App\Services\Logger;
class WebhookChannel implements NotificationChannelInterface
{
private Client $httpClient;
private Logger $logger;
// Supported webhook formats
public const FORMAT_GENERIC = 'generic';
public const FORMAT_GOOGLE_CHAT = 'google_chat';
public const FORMAT_SIMPLE_TEXT = 'simple_text';
public function __construct()
{
$this->httpClient = new Client(['timeout' => 10]);
$this->logger = new Logger('webhook_channel');
}
public function send(array $config, string $message, array $data = []): bool
{
$url = trim($config['webhook_url'] ?? '');
if (empty($url)) {
return false;
}
$format = $config['format'] ?? self::FORMAT_GENERIC;
$payload = $this->buildPayload($format, $message, $data);
try {
$response = $this->httpClient->post($url, [
'headers' => [
'Content-Type' => 'application/json; charset=UTF-8'
],
'json' => $payload
]);
$status = $response->getStatusCode();
$ok = $status >= 200 && $status < 300;
if ($ok) {
$this->logger->info('Webhook sent successfully', [
'url' => $this->maskUrl($url),
'format' => $format,
'status' => $status
]);
} else {
$responseBody = (string) $response->getBody();
$this->logger->error('Webhook responded with non-2xx', [
'url' => $this->maskUrl($url),
'format' => $format,
'status' => $status,
'response_body' => $this->truncate($responseBody, 1000),
'payload_preview' => $this->getPayloadPreview($payload)
]);
}
return $ok;
} catch (\GuzzleHttp\Exception\RequestException $e) {
// Guzzle request exception - may have response details
$errorDetails = [
'url' => $this->maskUrl($url),
'format' => $format,
'exception' => $e->getMessage(),
'payload_preview' => $this->getPayloadPreview($payload)
];
// Include response body if available (e.g., 4xx/5xx errors)
if ($e->hasResponse()) {
$response = $e->getResponse();
$errorDetails['status'] = $response->getStatusCode();
$errorDetails['response_body'] = $this->truncate((string) $response->getBody(), 1000);
}
$this->logger->error('Webhook request failed', $errorDetails);
return false;
} catch (\Exception $e) {
$this->logger->error('Webhook send failed', [
'url' => $this->maskUrl($url),
'format' => $format,
'exception' => $e->getMessage(),
'payload_preview' => $this->getPayloadPreview($payload)
]);
return false;
}
}
/**
* Build payload based on format type
*/
private function buildPayload(string $format, string $message, array $data): array
{
return match ($format) {
self::FORMAT_GOOGLE_CHAT => $this->buildGoogleChatPayload($message, $data),
self::FORMAT_SIMPLE_TEXT => ['text' => $message],
default => $this->buildGenericPayload($message, $data),
};
}
/**
* Build generic payload for automation tools (n8n, Zapier, Make, etc.)
*/
private function buildGenericPayload(string $message, array $data): array
{
return [
'event' => 'domain_monitor_alert',
'message' => $message,
'data' => $data,
'sent_at' => date('c')
];
}
/**
* Build Google Chat payload with rich card formatting
* Uses the official Google Chat webhook format
*/
private function buildGoogleChatPayload(string $message, array $data): array
{
// If we have domain data, create a rich card message
if (isset($data['domain']) && isset($data['expiration_date'])) {
return $this->buildGoogleChatCard($message, $data);
}
// Simple text message for test messages and other notifications
return ['text' => $message];
}
/**
* Build a rich card payload for Google Chat domain expiration alerts
*/
private function buildGoogleChatCard(string $message, array $data): array
{
$domain = $data['domain'] ?? 'Unknown';
$daysLeft = $data['days_left'] ?? 'N/A';
$expirationDate = $data['expiration_date'] ?? 'Unknown';
$registrar = $data['registrar'] ?? 'Unknown';
return [
'cardsV2' => [
[
'cardId' => 'domain-alert-' . time(),
'card' => [
'header' => [
'title' => 'Domain Expiration Alert',
'subtitle' => $domain,
'imageUrl' => 'https://www.gstatic.com/images/branding/product/2x/hats_notification_96dp.png',
'imageType' => 'CIRCLE'
],
'sections' => [
[
'header' => 'Alert Details',
'collapsible' => false,
'widgets' => [
[
'decoratedText' => [
'topLabel' => 'Domain',
'text' => $domain,
'startIcon' => [
'knownIcon' => 'BOOKMARK'
]
]
],
[
'decoratedText' => [
'topLabel' => 'Days Remaining',
'text' => (string) $daysLeft,
'startIcon' => [
'knownIcon' => 'CLOCK'
]
]
],
[
'decoratedText' => [
'topLabel' => 'Expiration Date',
'text' => $expirationDate,
'startIcon' => [
'knownIcon' => 'EVENT_SEAT'
]
]
],
[
'decoratedText' => [
'topLabel' => 'Registrar',
'text' => $registrar,
'startIcon' => [
'knownIcon' => 'STORE'
]
]
]
]
],
[
'widgets' => [
[
'textParagraph' => [
'text' => $message
]
]
]
]
]
]
]
]
];
}
/**
* Mask sensitive parts of URL for logging (hide keys/tokens)
*/
private function maskUrl(string $url): string
{
// Mask query parameters that might contain keys/tokens
return preg_replace('/([?&](key|token|secret|password|auth)=)[^&]+/i', '$1***MASKED***', $url);
}
/**
* Truncate string to max length for logging
*/
private function truncate(string $text, int $maxLength): string
{
if (strlen($text) <= $maxLength) {
return $text;
}
return substr($text, 0, $maxLength) . '... [truncated]';
}
/**
* Get a preview of the payload for debugging (without sensitive data)
*/
private function getPayloadPreview(array $payload): array
{
// For simple text payloads, show the text (truncated)
if (isset($payload['text'])) {
return [
'type' => 'text',
'text_preview' => $this->truncate($payload['text'], 200)
];
}
// For card payloads (Google Chat), show structure info
if (isset($payload['cardsV2'])) {
return [
'type' => 'google_chat_card',
'card_count' => count($payload['cardsV2'])
];
}
// For generic payloads, show event and truncated message
if (isset($payload['event'])) {
return [
'type' => 'generic',
'event' => $payload['event'],
'message_preview' => $this->truncate($payload['message'] ?? '', 200),
'data_keys' => isset($payload['data']) ? array_keys($payload['data']) : []
];
}
return ['type' => 'unknown', 'keys' => array_keys($payload)];
}
}