Skip to content

Notifications 2/6: generic webhook reference channel #221

Description

@LarsLaskowski

Second stage of the notification feature set. It provides the first concrete INotificationChannel implementation so the pipeline from #220 can actually deliver something. Depends on #220.

Full design: notification concept

Context

The notification channel type is intentionally not decided yet. Rather than committing the whole feature set to e-mail or a specific push service, the design uses a channel-agnostic abstraction plus one reference implementation. A generic HTTP webhook is the right first channel because it covers the widest range of targets with a single implementation: self-hosted push services (ntfy, Gotify), automation platforms (n8n, Home Assistant, Node-RED), and chat integrations behind a relay. Adding e-mail/SMTP later is a self-contained addition — one channel class, one options subsection, one resolver line — because the per-channel ledger and baseline logic from #220 already handle multiple channels.

Scope

Channel implementation

src/DockerUpdateGuard/Notifications/Channels/WebhookNotificationChannel.cs — singleton, Name = "Webhook", constructor takes IHttpClientFactory, IOptionsMonitor<DockerUpdateGuardOptions> and ILogger<WebhookNotificationChannel>.

  • IsEnabled reads Notifications.Enabled && Notifications.Webhook.Enabled from IOptionsMonitor.CurrentValue on every access, so configuration reloads take effect without a restart (the reason the channel is a singleton using the named client pattern from Portainer/PortainerClient.cs rather than a typed client with baked-in configuration).
  • SendAsync builds the request from the current options, applies the optional authorization header (AuthorizationHeaderName / AuthorizationHeaderValue), links a per-request CancellationTokenSource with RequestTimeoutSeconds to the caller's token, POSTs the serialized payload as application/json, and maps the outcome:
Situation Result
2xx response ExternalOperationResult<NotificationDeliveryData>.Succeeded(...) with DeliveredAtUtc and HttpStatusCode
Non-2xx response Failed(...) with the status code (and a truncated response snippet) in the message
Url missing/blank, or channel disabled NotConfigured(...)
HttpRequestException, timeout, serialization error caught → Failed(...)

The channel never throws across the interface — same convention as IVulnerabilityProvider and every other external integration in the repository.

Payload formats

WebhookPayloadFormat selects the serialization shape. Payload DTOs live under src/DockerUpdateGuard/Notifications/Data/ with the *Data suffix and are serialized with System.Text.Json using camelCase.

Generic (default) — the full document, intended for automation platforms that parse structured data:

{
  "source": "DockerUpdateGuard",
  "version": 1,
  "generatedAtUtc": "2026-07-27T18:00:00Z",
  "title": "DockerUpdateGuard: 2 updates, 1 critical vulnerability",
  "summary": "Plain-text digest of all items",
  "updates": [
    {
      "kind": "ContainerUpdateAvailable",
      "subject": "prod / web-1",
      "currentVersion": "nginx:1.27.1",
      "recommendedVersion": "1.29.0"
    }
  ],
  "vulnerabilities": [
    {
      "advisoryId": "CVE-2026-1234",
      "severity": "Critical",
      "package": "openssl",
      "subject": "nginx:1.27.1"
    }
  ],
  "truncatedItemCount": 0
}

The version field is an explicit payload-schema version so receivers can tolerate future additions.

Ntfytitle, message (the plain-text summary), priority, tags; posted to the configured topic URL. Priority 5 when the digest contains vulnerabilities, otherwise 3; tags chosen per content (e.g. warning/package).

Gotifytitle, message, priority; the application token is supplied by the user either in the URL query or via the configurable authorization header, so no Gotify-specific option is needed.

HTTP client and DI

In src/DockerUpdateGuard/ServiceCollectionExtensions.cs:

services.AddHttpClient(WebhookNotificationChannel.HttpClientName)
        .AddHttpMessageHandler<TransientHttpRetryHandler>();

services.AddSingleton<WebhookNotificationChannel>();

and inject the channel into NotificationChannelResolver, which returns it when IsEnabled is true. TransientHttpRetryHandler gives the same in-request retry behavior (5xx/408/timeouts) that every other outbound client in the application has; the cross-run outbox retry is a separate layer added in #224.

Testing

src/Tests/DockerUpdateGuard.Tests/WebhookNotificationChannelTests.cs using the existing HTTP fakes (Helper/StubHttpMessageHandler.cs, StaticHttpJsonMessageHandler.cs, SequenceHttpMessageHandler.cs, TimeoutHttpMessageHandler.cs) plus TestOptionsMonitor<DockerUpdateGuardOptions> and TestLogger<T>:

  • Generic payload contains all fields, correct casing, ISO-8601 UTC timestamp, and the truncated-item count
  • Ntfy payload maps title/message/priority/tags, priority escalates when vulnerabilities are present
  • Gotify payload maps title/message/priority
  • authorization header applied with the configured name; omitted when the value is null
  • 2xx → Succeeded with status code in NotificationDeliveryData
  • 4xx/5xx → Failed with the status code in the message
  • request timeout → Failed, and the caller's cancellation token still propagates cancellation
  • missing/blank UrlNotConfigured
  • plaintext HTTP without AllowInsecureHttpNotConfigured
  • channel disabled → IsEnabled false and SendAsync returns NotConfigured without issuing a request

Extend NotificationChannelResolverTests: webhook enabled → returned; webhook disabled → not returned; master switch off → empty list regardless of the webhook flag.

Acceptance criteria

  • All three payload formats produce the documented JSON shape, asserted against captured request bodies
  • Every failure mode surfaces as an ExternalOperationResult status — no exception escapes SendAsync
  • The authorization header, request timeout, and AllowInsecureHttp guard behave as specified
  • The named HttpClient is registered with TransientHttpRetryHandler and asserted in ServiceCollectionExtensionsTests
  • The resolver returns the webhook channel exactly when both the master switch and the channel flag are enabled
  • Manual verification: with NotifyPreExistingFindings = true and a real ntfy/webhook endpoint, a digest arrives (note the result in the PR description)
  • reihitsu-format ./ clean, build without new analyzer findings, all tests green

Out of scope

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions