From 66e89ce7074bb53eef62b302dce4bd080c6bbde4 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 20:59:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(webhook):=20test=20ping=20=E2=80=94=20admi?= =?UTF-8?q?n=20=D0=B2=D0=B5=D1=80=D0=B8=D1=84=D0=B8=D1=86=D0=B8=D1=80?= =?UTF-8?q?=D1=83=D0=B5=D1=82=20receiver=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После создания subscription / rotate secret admin не имел способа проверить что receiver жив и валидирует HMAC без ожидания реального business event. Теперь: - POST /api/v1/admin/webhooks/subscriptions/{id}/test - Synchronous: receiver получает synthetic event с теми же headers что у production delivery (X-Ordinis-Signature, X-Ordinis-Event-Type, X-Ordinis-Scope) + дополнительный X-Ordinis-Test=true чтобы receiver мог логировать/филтровать как тест. - HMAC sign'ится через тот же HmacSigner — admin раскопировал secret правильно если receiver валидирует. Иначе receiver вернёт 4xx и UI покажет ошибку. - SSRF guard validate URL host (private CIDR + allowed-hosts override) — same policy как у dispatcher. Bypass'ит outbox/dispatcher — нет webhook_deliveries row, нет attempt count, нет следов в аудите доставок (это test, не business event). UI: - Кнопка 'Тест ping' с PaperPlaneTilt иконкой рядом с 'Сменить secret' на webhook detail page. - Inline Alert после ответа: success/failure/rejected с HTTP status, latency, body preview / error message. Dismiss '✕'. RBAC: RESTRICTED — endpoint отправляет HTTP request на user-controlled URL (potential abuse vector если бы был INTERNAL). Tests: rest-api compiles, 76 admin-ui passed. --- ordinis-admin-ui/src/api/client.ts | 8 + ordinis-admin-ui/src/api/mutations.ts | 19 ++ ordinis-admin-ui/src/i18n.ts | 10 ++ ordinis-admin-ui/src/routes/webhooks.$id.tsx | 84 +++++++++ .../webhook/WebhookTestPingService.java | 163 ++++++++++++++++++ .../WebhookSubscriptionController.java | 20 +++ 6 files changed, 304 insertions(+) create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookTestPingService.java diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 7393078..85629b1 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -229,3 +229,11 @@ export type WebhookDeliveryPage = { number: number size: number } + +export type WebhookTestPingResult = { + /** success | failure | rejected */ + status: 'success' | 'failure' | 'rejected' + httpStatus?: number | null + latencyMs?: number | null + message?: string | null +} diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index 4ab85bf..509dfbf 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -8,6 +8,7 @@ import { type RecordResponse, type WebhookDelivery, type WebhookSubscription, + type WebhookTestPingResult, } from './client' const idempotencyKey = () => @@ -166,6 +167,24 @@ export const useRotateWebhookSecret = () => { }) } +/** + * Test ping — receiver получает synthetic event с тем же HMAC и headers + * что у production delivery. Возвращает result sync для inline feedback. + * + * Use case: после создания subscription / rotate secret — verify что + * receiver достижим, валидирует HMAC, не возвращает 5xx. + */ +export const useTestWebhook = () => { + return useMutation({ + mutationFn: async (id: string): Promise => { + const { data } = await apiClient.post( + `/admin/webhooks/subscriptions/${encodeURIComponent(id)}/test`, + ) + return data + }, + }) +} + /** * Replay delivery из DLQ или retrying — backend сбрасывает attemptCount=0, * dlqAt=null, status=retrying. Dispatcher подхватит на следующем sendTick. diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 1101b2b..5c0a8fc 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -34,6 +34,11 @@ i18n 'webhooks.action.create': 'Создать', 'webhooks.action.delete': 'Удалить', 'webhooks.action.rotateSecret': 'Сменить secret', + 'webhooks.action.test': 'Тест ping', + 'webhooks.test.success': 'Receiver получил test event и ответил 2xx', + 'webhooks.test.failure': 'Receiver не принял test event', + 'webhooks.test.rejected': 'Запрос не отправлен (SSRF guard / serialization)', + 'webhooks.test.latency': 'latency', 'webhooks.confirmDelete': 'Удалить подписку «{{name}}»? Все pending deliveries удалятся каскадом.', 'webhooks.confirmRotate': 'Сменить HMAC secret? Старый сразу перестанет работать — receiver надо обновить заранее.', 'webhooks.field.name': 'Имя подписки', @@ -285,6 +290,11 @@ i18n 'webhooks.action.create': 'Create', 'webhooks.action.delete': 'Delete', 'webhooks.action.rotateSecret': 'Rotate secret', + 'webhooks.action.test': 'Test ping', + 'webhooks.test.success': 'Receiver got the test event and replied 2xx', + 'webhooks.test.failure': 'Receiver rejected the test event', + 'webhooks.test.rejected': 'Request not sent (SSRF guard / serialization)', + 'webhooks.test.latency': 'latency', 'webhooks.confirmDelete': 'Delete subscription "{{name}}"? All pending deliveries will be cascade-deleted.', 'webhooks.confirmRotate': 'Rotate HMAC secret? The old one stops working immediately — update the receiver first.', 'webhooks.field.name': 'Subscription name', diff --git a/ordinis-admin-ui/src/routes/webhooks.$id.tsx b/ordinis-admin-ui/src/routes/webhooks.$id.tsx index f697a10..7355126 100644 --- a/ordinis-admin-ui/src/routes/webhooks.$id.tsx +++ b/ordinis-admin-ui/src/routes/webhooks.$id.tsx @@ -21,6 +21,7 @@ import { ArrowClockwiseIcon, ArrowsClockwiseIcon, KeyIcon, + PaperPlaneTiltIcon, TrashIcon, } from '@phosphor-icons/react' import { @@ -31,7 +32,9 @@ import { useDeleteWebhook, useRetryWebhookDelivery, useRotateWebhookSecret, + useTestWebhook, } from '@/api/mutations' +import type { WebhookTestPingResult } from '@/api/client' import { SCOPE_DOT } from '@/lib/scope-style' export const Route = createFileRoute('/webhooks/$id')({ @@ -54,6 +57,20 @@ function WebhookDetailPage() { const rotateMut = useRotateWebhookSecret() const deleteMut = useDeleteWebhook() const retryMut = useRetryWebhookDelivery() + const testMut = useTestWebhook() + const [testResult, setTestResult] = useState(null) + + const handleTest = () => { + setTestResult(null) + testMut.mutate(id, { + onSuccess: (r) => setTestResult(r), + onError: (e) => + setTestResult({ + status: 'rejected', + message: e instanceof Error ? e.message : String(e), + }), + }) + } const handleRetry = (deliveryId: number) => { if (!window.confirm(t('webhooks.deliveries.confirmRetry'))) return @@ -101,6 +118,14 @@ function WebhookDetailPage() { description={data.description ?? data.url} actions={
+ + } + > +
+ {result.httpStatus != null && ( +
+ + HTTP + + {result.httpStatus} +
+ )} + {result.latencyMs != null && ( +
+ + {t('webhooks.test.latency')} + + {result.latencyMs} ms +
+ )} + {result.message && ( +
+ {result.message} +
+ )} +
+ + ) +} + function InfoRow({ label, children }: { label: string; children: React.ReactNode }) { return (
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookTestPingService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookTestPingService.java new file mode 100644 index 0000000..1f9e51d --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookTestPingService.java @@ -0,0 +1,163 @@ +package cloud.nstart.terravault.ordinis.restapi.service.webhook; + +import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscription; +import cloud.nstart.terravault.ordinis.outbox.webhook.HmacSigner; +import cloud.nstart.terravault.ordinis.outbox.webhook.SsrfGuard; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Synchronous test ping для webhook subscription. Admin кликает кнопку в UI → + * receiver получает synthetic event ровно с теми же headers/HMAC что и + * production delivery, ответ возвращается сразу для inline feedback. + * + *

Use case: после создания subscription verify что: + *

    + *
  • URL достижим (DNS, network, TLS) + *
  • Receiver не отвергает HMAC signature (admin раскопировал secret правильно) + *
  • Receiver обрабатывает payload без 5xx + *
+ * + *

Bypass'ит outbox/dispatcher — sync request, immediate response. Не + * порождает webhook_deliveries row (test event, не реальный business event). + * + *

SSRF guard validate'ит URL host против allowed-hosts list (override) + + * private CIDR check. Тот же что у dispatcher — единая security policy. + */ +@Service +public class WebhookTestPingService { + + private static final Logger log = LoggerFactory.getLogger(WebhookTestPingService.class); + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); + + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + + @Value("${ordinis.webhook.allowed-hosts:}") + private String allowedHostsCsv; + + private SsrfGuard ssrfGuard; + + public WebhookTestPingService(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .build(); + } + + @PostConstruct + void init() { + List allowed = allowedHostsCsv == null || allowedHostsCsv.isBlank() + ? List.of() + : Arrays.stream(allowedHostsCsv.split(",")).map(String::trim).toList(); + this.ssrfGuard = new SsrfGuard(allowed); + } + + public Result testPing(WebhookSubscription sub) { + String ssrfReason = ssrfGuard.validate(sub.getUrl()); + if (ssrfReason != null) { + return Result.rejected("SSRF guard rejected URL: " + ssrfReason); + } + + Map payload = buildSyntheticPayload(sub); + byte[] body; + try { + body = objectMapper.writeValueAsBytes(payload); + } catch (JsonProcessingException e) { + return Result.rejected("Failed to serialize test payload: " + e.getMessage()); + } + String signature = HmacSigner.sign(sub.getHmacSecret(), body); + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(sub.getUrl())) + .header("Content-Type", "application/json") + .header(HmacSigner.HEADER_NAME, signature) + .header("X-Ordinis-Event-Id", "test-ping") + .header("X-Ordinis-Event-Type", "test.ping") + .header("X-Ordinis-Scope", "PUBLIC") + .header("X-Ordinis-Test", "true") + .timeout(REQUEST_TIMEOUT) + .POST(BodyPublishers.ofByteArray(body)) + .build(); + + long start = System.nanoTime(); + try { + HttpResponse resp = httpClient.send(req, BodyHandlers.ofString(StandardCharsets.UTF_8)); + long ms = (System.nanoTime() - start) / 1_000_000L; + int status = resp.statusCode(); + String preview = trim(resp.body(), 200); + if (status >= 200 && status < 300) { + return Result.success(status, ms, preview); + } + return Result.failure(status, ms, "HTTP " + status + " — " + preview); + } catch (Exception e) { + long ms = (System.nanoTime() - start) / 1_000_000L; + log.warn("Test ping to {} failed", sub.getUrl(), e); + return Result.failure(null, ms, e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + + private Map buildSyntheticPayload(WebhookSubscription sub) { + Map p = new LinkedHashMap<>(); + p.put("eventId", "test-ping"); + p.put("eventType", "test.ping"); + p.put("scope", "PUBLIC"); + p.put("subscriptionId", sub.getId().toString()); + p.put("subscriptionName", sub.getName()); + p.put("timestamp", OffsetDateTime.now().toString()); + p.put("message", "Test event from Ordinis admin. Если receiver получил — он живой и валидирует HMAC."); + return p; + } + + private static String trim(String s, int max) { + if (s == null) return ""; + return s.length() <= max ? s : s.substring(0, max) + "…"; + } + + /** + * Result test ping'а. status: + *

    + *
  • success — HTTP 2xx + *
  • failure — HTTP non-2xx ИЛИ network exception (timeout, connection refused) + *
  • rejected — SSRF guard / serialization error, request даже не отправлен + *
+ */ + public record Result( + String status, + Integer httpStatus, + Long latencyMs, + String message) { + + public static Result success(int httpStatus, long latencyMs, String body) { + return new Result("success", httpStatus, latencyMs, body); + } + + public static Result failure(Integer httpStatus, long latencyMs, String error) { + return new Result("failure", httpStatus, latencyMs, error); + } + + public static Result rejected(String reason) { + return new Result("rejected", null, null, reason); + } + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/webhook/WebhookSubscriptionController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/webhook/WebhookSubscriptionController.java index 50c108e..b2c9a27 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/webhook/WebhookSubscriptionController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/webhook/WebhookSubscriptionController.java @@ -11,6 +11,7 @@ import cloud.nstart.terravault.ordinis.restapi.dto.webhook.WebhookDeliveryRespon import cloud.nstart.terravault.ordinis.restapi.dto.webhook.WebhookSubscriptionResponse; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; import cloud.nstart.terravault.ordinis.restapi.service.webhook.WebhookSubscriptionService; +import cloud.nstart.terravault.ordinis.restapi.service.webhook.WebhookTestPingService; import jakarta.validation.Valid; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -55,16 +56,19 @@ public class WebhookSubscriptionController { private final WebhookSubscriptionService service; private final WebhookSubscriptionRepository subscriptionRepository; private final WebhookDeliveryRepository deliveryRepository; + private final WebhookTestPingService testPingService; private final ScopeContext scopeContext; public WebhookSubscriptionController( WebhookSubscriptionService service, WebhookSubscriptionRepository subscriptionRepository, WebhookDeliveryRepository deliveryRepository, + WebhookTestPingService testPingService, ScopeContext scopeContext) { this.service = service; this.subscriptionRepository = subscriptionRepository; this.deliveryRepository = deliveryRepository; + this.testPingService = testPingService; this.scopeContext = scopeContext; } @@ -152,6 +156,22 @@ public class WebhookSubscriptionController { * *

RBAC: RESTRICTED — это destructive op (можно spam'ить receiver). */ + /** + * Synchronous test ping — admin кликает кнопку в UI, receiver получает + * synthetic event с теми же headers/HMAC что у production delivery. + * Возвращает result inline для immediate feedback (latency, http status, + * body preview / error). + * + *

Не порождает webhook_deliveries row — это test, не business event. + * RBAC: RESTRICTED — потому что отправляет HTTP request на user-controlled URL. + */ + @PostMapping("/subscriptions/{id}/test") + public WebhookTestPingService.Result testSubscription(@PathVariable UUID id) { + requireAdmin(); + WebhookSubscription sub = service.findById(id); + return testPingService.testPing(sub); + } + @PostMapping("/deliveries/{id}/retry") public WebhookDeliveryResponse retryDelivery(@PathVariable Long id) { requireAdmin();