diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/WebhookDispatcher.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/WebhookDispatcher.java new file mode 100644 index 00000000..c8616bcc --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/WebhookDispatcher.java @@ -0,0 +1,213 @@ +package com.cameleer.server.app.alerting.notify; + +import com.cameleer.server.app.alerting.config.AlertingProperties; +import com.cameleer.server.app.outbound.crypto.SecretCipher; +import com.cameleer.server.core.alerting.AlertInstance; +import com.cameleer.server.core.alerting.AlertNotification; +import com.cameleer.server.core.alerting.AlertRule; +import com.cameleer.server.core.alerting.NotificationStatus; +import com.cameleer.server.core.alerting.WebhookBinding; +import com.cameleer.server.core.http.OutboundHttpClientFactory; +import com.cameleer.server.core.http.OutboundHttpRequestContext; +import com.cameleer.server.core.outbound.OutboundConnection; +import com.cameleer.server.core.outbound.OutboundMethod; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.hc.client5.http.classic.methods.HttpPatch; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.classic.methods.HttpPut; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Renders, signs, and dispatches webhook notifications over HTTP. + *

+ * Classification: + *

+ */ +@Component +public class WebhookDispatcher { + + private static final Logger log = LoggerFactory.getLogger(WebhookDispatcher.class); + + /** baseDelay that callers multiply by attempt count: 30s, 60s, 90s, … */ + static final Duration BASE_RETRY_DELAY = Duration.ofSeconds(30); + + private static final int SNIPPET_LIMIT = 512; + private static final String DEFAULT_CONTENT_TYPE = "application/json"; + + private final OutboundHttpClientFactory clientFactory; + private final SecretCipher secretCipher; + private final MustacheRenderer renderer; + private final AlertingProperties props; + private final ObjectMapper objectMapper; + + public WebhookDispatcher(OutboundHttpClientFactory clientFactory, + SecretCipher secretCipher, + MustacheRenderer renderer, + AlertingProperties props, + ObjectMapper objectMapper) { + this.clientFactory = clientFactory; + this.secretCipher = secretCipher; + this.renderer = renderer; + this.props = props; + this.objectMapper = objectMapper; + } + + public record Outcome( + NotificationStatus status, + int httpStatus, + String snippet, + Duration retryAfter) {} + + /** + * Dispatch a single webhook notification. + * + * @param notif the outbox record (contains webhookId used to find per-rule overrides) + * @param rule the alert rule (may be null when rule was deleted) + * @param instance the alert instance + * @param conn the resolved outbound connection + * @param context the Mustache rendering context + */ + public Outcome dispatch(AlertNotification notif, + AlertRule rule, + AlertInstance instance, + OutboundConnection conn, + Map context) { + try { + // 1. Determine per-binding overrides + WebhookBinding binding = findBinding(rule, notif); + + // 2. Render URL + String url = renderer.render(conn.url(), context); + + // 3. Build body + String body = buildBody(conn, binding, context); + + // 4. Build headers + Map headers = buildHeaders(conn, binding, context); + + // 5. HMAC sign if configured + if (conn.hmacSecretCiphertext() != null) { + String secret = secretCipher.decrypt(conn.hmacSecretCiphertext()); + String sig = new HmacSigner().sign(secret, body.getBytes(StandardCharsets.UTF_8)); + headers.put("X-Cameleer-Signature", sig); + } + + // 6. Build HTTP request + Duration timeout = Duration.ofMillis(props.effectiveWebhookTimeoutMs()); + OutboundHttpRequestContext ctx = new OutboundHttpRequestContext( + conn.tlsTrustMode(), conn.tlsCaPemPaths(), timeout, timeout); + + var client = clientFactory.clientFor(ctx); + HttpUriRequestBase request = buildRequest(conn.method(), url); + for (var e : headers.entrySet()) { + request.setHeader(e.getKey(), e.getValue()); + } + request.setEntity(new StringEntity(body, StandardCharsets.UTF_8)); + + // 7. Execute and classify + try (var response = client.execute(request)) { + int code = response.getCode(); + String snippet = snippet(response.getEntity() != null + ? EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8) + : ""); + + if (code >= 200 && code < 300) { + return new Outcome(NotificationStatus.DELIVERED, code, snippet, null); + } else if (code >= 400 && code < 500) { + return new Outcome(NotificationStatus.FAILED, code, snippet, null); + } else { + return new Outcome(null, code, snippet, BASE_RETRY_DELAY); + } + } + + } catch (Exception e) { + log.warn("WebhookDispatcher: network/timeout error dispatching notification {}: {}", + notif.id(), e.getMessage()); + return new Outcome(null, 0, snippet(e.getMessage()), BASE_RETRY_DELAY); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private WebhookBinding findBinding(AlertRule rule, AlertNotification notif) { + if (rule == null || notif.webhookId() == null) return null; + return rule.webhooks().stream() + .filter(w -> w.id().equals(notif.webhookId())) + .findFirst() + .orElse(null); + } + + private String buildBody(OutboundConnection conn, WebhookBinding binding, Map context) { + // Priority: per-binding override > connection default > built-in JSON envelope + String tmpl = null; + if (binding != null && binding.bodyOverride() != null) { + tmpl = binding.bodyOverride(); + } else if (conn.defaultBodyTmpl() != null) { + tmpl = conn.defaultBodyTmpl(); + } + + if (tmpl != null) { + return renderer.render(tmpl, context); + } + + // Built-in default: serialize the entire context map as JSON + try { + return objectMapper.writeValueAsString(context); + } catch (Exception e) { + log.warn("WebhookDispatcher: failed to serialize context as JSON, using empty object", e); + return "{}"; + } + } + + private Map buildHeaders(OutboundConnection conn, WebhookBinding binding, + Map context) { + Map headers = new LinkedHashMap<>(); + + // Default content-type + headers.put("Content-Type", DEFAULT_CONTENT_TYPE); + + // Connection-level default headers (keys are literal, values are Mustache-rendered) + for (var e : conn.defaultHeaders().entrySet()) { + headers.put(e.getKey(), renderer.render(e.getValue(), context)); + } + + // Per-binding overrides (also Mustache-rendered values) + if (binding != null) { + for (var e : binding.headerOverrides().entrySet()) { + headers.put(e.getKey(), renderer.render(e.getValue(), context)); + } + } + + return headers; + } + + private HttpUriRequestBase buildRequest(OutboundMethod method, String url) { + if (method == null) method = OutboundMethod.POST; + return switch (method) { + case PUT -> new HttpPut(url); + case PATCH -> new HttpPatch(url); + default -> new HttpPost(url); + }; + } + + private String snippet(String text) { + if (text == null) return ""; + return text.length() <= SNIPPET_LIMIT ? text : text.substring(0, SNIPPET_LIMIT); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/WebhookDispatcherIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/WebhookDispatcherIT.java new file mode 100644 index 00000000..cd83c44e --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/WebhookDispatcherIT.java @@ -0,0 +1,235 @@ +package com.cameleer.server.app.alerting.notify; + +import com.cameleer.server.app.alerting.config.AlertingProperties; +import com.cameleer.server.app.http.ApacheOutboundHttpClientFactory; +import com.cameleer.server.app.http.SslContextBuilder; +import com.cameleer.server.app.outbound.crypto.SecretCipher; +import com.cameleer.server.core.alerting.*; +import com.cameleer.server.core.http.OutboundHttpProperties; +import com.cameleer.server.core.http.TrustMode; +import com.cameleer.server.core.outbound.OutboundAuth; +import com.cameleer.server.core.outbound.OutboundConnection; +import com.cameleer.server.core.outbound.OutboundMethod; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * WireMock-backed integration tests for {@link WebhookDispatcher}. + * Each test spins its own WireMock server (HTTP on random port, or HTTPS for TLS test). + */ +class WebhookDispatcherIT { + + private static final String JWT_SECRET = "very-secret-jwt-key-for-test-only-32chars"; + + private WireMockServer wm; + private WebhookDispatcher dispatcher; + private SecretCipher cipher; + + @BeforeEach + void setUp() { + wm = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + wm.start(); + + OutboundHttpProperties props = new OutboundHttpProperties( + false, List.of(), Duration.ofSeconds(2), Duration.ofSeconds(5), null, null, null); + cipher = new SecretCipher(JWT_SECRET); + dispatcher = new WebhookDispatcher( + new ApacheOutboundHttpClientFactory(props, new SslContextBuilder()), + cipher, + new MustacheRenderer(), + new AlertingProperties(null, null, null, null, null, null, null, null, null, null, null, null, null), + new ObjectMapper() + ); + } + + @AfterEach + void tearDown() { + if (wm != null) wm.stop(); + } + + // ------------------------------------------------------------------------- + // Tests + // ------------------------------------------------------------------------- + + @Test + void twoHundredRespond_isDelivered() { + wm.stubFor(post("/webhook").willReturn(aResponse().withStatus(200).withBody("accepted"))); + + var outcome = dispatcher.dispatch( + notif(null), null, instance(), conn(wm.port(), OutboundMethod.POST, null, Map.of(), null), ctx()); + + assertThat(outcome.status()).isEqualTo(NotificationStatus.DELIVERED); + assertThat(outcome.httpStatus()).isEqualTo(200); + assertThat(outcome.snippet()).isEqualTo("accepted"); + assertThat(outcome.retryAfter()).isNull(); + } + + @Test + void fourOhFour_isFailedImmediately() { + wm.stubFor(post("/webhook").willReturn(aResponse().withStatus(404).withBody("not found"))); + + var outcome = dispatcher.dispatch( + notif(null), null, instance(), conn(wm.port(), OutboundMethod.POST, null, Map.of(), null), ctx()); + + assertThat(outcome.status()).isEqualTo(NotificationStatus.FAILED); + assertThat(outcome.httpStatus()).isEqualTo(404); + assertThat(outcome.retryAfter()).isNull(); + } + + @Test + void fiveOhThree_hasNullStatusAndRetryDelay() { + wm.stubFor(post("/webhook").willReturn(aResponse().withStatus(503).withBody("unavailable"))); + + var outcome = dispatcher.dispatch( + notif(null), null, instance(), conn(wm.port(), OutboundMethod.POST, null, Map.of(), null), ctx()); + + assertThat(outcome.status()).isNull(); + assertThat(outcome.httpStatus()).isEqualTo(503); + assertThat(outcome.retryAfter()).isEqualTo(Duration.ofSeconds(30)); + } + + @Test + void hmacHeader_presentWhenSecretSet() { + wm.stubFor(post("/webhook").willReturn(ok("ok"))); + + // Encrypt a test secret + String ciphertext = cipher.encrypt("my-signing-secret"); + var outcome = dispatcher.dispatch( + notif(null), null, instance(), conn(wm.port(), OutboundMethod.POST, ciphertext, Map.of(), null), ctx()); + + assertThat(outcome.status()).isEqualTo(NotificationStatus.DELIVERED); + wm.verify(postRequestedFor(urlEqualTo("/webhook")) + .withHeader("X-Cameleer-Signature", matching("sha256=[0-9a-f]{64}"))); + } + + @Test + void hmacHeader_absentWhenNoSecret() { + wm.stubFor(post("/webhook").willReturn(ok("ok"))); + + dispatcher.dispatch( + notif(null), null, instance(), conn(wm.port(), OutboundMethod.POST, null, Map.of(), null), ctx()); + + wm.verify(postRequestedFor(urlEqualTo("/webhook")) + .withoutHeader("X-Cameleer-Signature")); + } + + @Test + void putMethod_isRespected() { + wm.stubFor(put("/webhook").willReturn(ok("ok"))); + + var outcome = dispatcher.dispatch( + notif(null), null, instance(), conn(wm.port(), OutboundMethod.PUT, null, Map.of(), null), ctx()); + + assertThat(outcome.status()).isEqualTo(NotificationStatus.DELIVERED); + wm.verify(putRequestedFor(urlEqualTo("/webhook"))); + } + + @Test + void customHeaderRenderedWithMustache() { + wm.stubFor(post("/webhook").willReturn(ok("ok"))); + + // "{{env.slug}}" in the defaultHeaders value should resolve to "dev" from context + var headers = Map.of("X-Env", "{{env.slug}}"); + var outcome = dispatcher.dispatch( + notif(null), null, instance(), + conn(wm.port(), OutboundMethod.POST, null, headers, null), + ctxWithEnv("dev")); + + assertThat(outcome.status()).isEqualTo(NotificationStatus.DELIVERED); + wm.verify(postRequestedFor(urlEqualTo("/webhook")) + .withHeader("X-Env", equalTo("dev"))); + } + + @Test + void tlsTrustAll_worksAgainstSelfSignedCert() throws Exception { + // Separate WireMock instance with HTTPS only + WireMockServer wmHttps = new WireMockServer( + WireMockConfiguration.options().httpDisabled(true).dynamicHttpsPort()); + wmHttps.start(); + wmHttps.stubFor(post("/webhook").willReturn(ok("secure-ok"))); + + try { + // Connection with TRUST_ALL so the self-signed cert is accepted + var conn = connHttps(wmHttps.httpsPort(), OutboundMethod.POST, null, Map.of()); + var outcome = dispatcher.dispatch(notif(null), null, instance(), conn, ctx()); + assertThat(outcome.status()).isEqualTo(NotificationStatus.DELIVERED); + assertThat(outcome.snippet()).isEqualTo("secure-ok"); + } finally { + wmHttps.stop(); + } + } + + // ------------------------------------------------------------------------- + // Builders + // ------------------------------------------------------------------------- + + private AlertNotification notif(UUID webhookId) { + return new AlertNotification( + UUID.randomUUID(), UUID.randomUUID(), + webhookId, UUID.randomUUID(), + NotificationStatus.PENDING, 0, Instant.now(), + null, null, null, null, Map.of(), null, Instant.now()); + } + + private AlertInstance instance() { + return new AlertInstance( + UUID.randomUUID(), UUID.randomUUID(), Map.of(), + UUID.randomUUID(), AlertState.FIRING, AlertSeverity.WARNING, + Instant.now(), null, null, null, null, false, + null, null, Map.of(), "Alert", "Message", + List.of(), List.of(), List.of()); + } + + private OutboundConnection conn(int port, OutboundMethod method, String hmacCiphertext, + Map defaultHeaders, String bodyTmpl) { + return new OutboundConnection( + UUID.randomUUID(), "default", "test-conn", null, + "http://localhost:" + port + "/webhook", + method, defaultHeaders, bodyTmpl, + TrustMode.SYSTEM_DEFAULT, List.of(), + hmacCiphertext, new OutboundAuth.None(), + List.of(), Instant.now(), "system", Instant.now(), "system"); + } + + private OutboundConnection connHttps(int port, OutboundMethod method, String hmacCiphertext, + Map defaultHeaders) { + return new OutboundConnection( + UUID.randomUUID(), "default", "test-conn-https", null, + "https://localhost:" + port + "/webhook", + method, defaultHeaders, null, + TrustMode.TRUST_ALL, List.of(), + hmacCiphertext, new OutboundAuth.None(), + List.of(), Instant.now(), "system", Instant.now(), "system"); + } + + private Map ctx() { + return Map.of( + "env", Map.of("slug", "prod", "id", UUID.randomUUID().toString()), + "rule", Map.of("name", "test-rule", "severity", "WARNING", "id", UUID.randomUUID().toString(), "description", ""), + "alert", Map.of("id", UUID.randomUUID().toString(), "state", "FIRING", "firedAt", Instant.now().toString(), + "resolvedAt", "", "ackedBy", "", "link", "/alerts/inbox/x", "currentValue", "", "threshold", "") + ); + } + + private Map ctxWithEnv(String envSlug) { + return Map.of( + "env", Map.of("slug", envSlug, "id", UUID.randomUUID().toString()), + "rule", Map.of("name", "test-rule", "severity", "WARNING", "id", UUID.randomUUID().toString(), "description", ""), + "alert", Map.of("id", UUID.randomUUID().toString(), "state", "FIRING", "firedAt", Instant.now().toString(), + "resolvedAt", "", "ackedBy", "", "link", "/alerts/inbox/x", "currentValue", "", "threshold", "") + ); + } +}