diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts index a1cb79c60..52c687cbc 100644 --- a/server/private/lib/alerts/sendAlertWebhook.ts +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -55,7 +55,7 @@ export async function sendAlertWebhook( let response: Response; try { response = await fetch(url, { - method: "POST", + method: webhookConfig.method ?? "POST", headers, body, signal: controller.signal @@ -128,5 +128,13 @@ function buildHeaders(webhookConfig: WebhookAlertConfig): Record break; } + if (webhookConfig.headers) { + for (const { key, value } of webhookConfig.headers) { + if (key.trim()) { + headers[key.trim()] = value; + } + } + } + return headers; } \ No newline at end of file diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts index 626c2710f..e79db2ef5 100644 --- a/server/private/lib/alerts/types.ts +++ b/server/private/lib/alerts/types.ts @@ -41,6 +41,10 @@ export interface WebhookAlertConfig { customHeaderName?: string; /** Custom header value – used when authType === "custom" */ customHeaderValue?: string; + /** Extra headers to send with every webhook request */ + headers?: Array<{ key: string; value: string }>; + /** HTTP method (default POST) */ + method?: string; } // --------------------------------------------------------------------------- diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index fa547661b..40408d898 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -28,6 +28,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; const HC_EVENT_TYPES = [ @@ -247,11 +249,12 @@ export async function createAlertRule( } if (webhookActions.length > 0) { + const serverSecret = config.getRawConfig().server.secret!; await db.insert(alertWebhookActions).values( webhookActions.map((wa) => ({ alertRuleId: rule.alertRuleId, webhookUrl: wa.webhookUrl, - config: wa.config ?? null, + config: wa.config != null ? encrypt(wa.config, serverSecret) : null, enabled: wa.enabled })) ); diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index a493cd279..5d307316b 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -29,6 +29,9 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; +import { decrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import { WebhookAlertConfig } from "@server/lib/alerts/types"; const paramsSchema = z .object({ @@ -64,6 +67,7 @@ export type GetAlertRuleResponse = { webhookUrl: string; enabled: boolean; lastSentAt: number | null; + config: WebhookAlertConfig | null; }[]; }; @@ -172,12 +176,25 @@ export async function getAlertRule( siteIds: siteRows.map((r) => r.siteId), healthCheckIds: healthCheckRows.map((r) => r.healthCheckId), recipients, - webhookActions: webhooks.map((w) => ({ - webhookActionId: w.webhookActionId, - webhookUrl: w.webhookUrl, - enabled: w.enabled, - lastSentAt: w.lastSentAt ?? null - })) + webhookActions: webhooks.map((w) => { + let parsedConfig: WebhookAlertConfig | null = null; + if (w.config) { + try { + const serverSecret = config.getRawConfig().server.secret!; + const decrypted = decrypt(w.config, serverSecret); + parsedConfig = JSON.parse(decrypted) as WebhookAlertConfig; + } catch { + // best-effort – return null if decryption fails + } + } + return { + webhookActionId: w.webhookActionId, + webhookUrl: w.webhookUrl, + enabled: w.enabled, + lastSentAt: w.lastSentAt ?? null, + config: parsedConfig + }; + }) }, success: true, error: false, diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 4cbd3795a..add031dc4 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -29,6 +29,8 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; const HC_EVENT_TYPES = [ @@ -302,11 +304,12 @@ export async function updateAlertRule( .where(eq(alertWebhookActions.alertRuleId, alertRuleId)); if (webhookActions.length > 0) { + const serverSecret = config.getRawConfig().server.secret!; await db.insert(alertWebhookActions).values( webhookActions.map((wa) => ({ alertRuleId, webhookUrl: wa.webhookUrl, - config: wa.config ?? null, + config: wa.config != null ? encrypt(wa.config, serverSecret) : null, enabled: wa.enabled })) ); diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 824fc1b10..8af7e780c 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -30,6 +30,11 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; +import { + RadioGroup, + RadioGroupItem +} from "@app/components/ui/radio-group"; +import { Label } from "@app/components/ui/label"; import { TagInput, type Tag } from "@app/components/tags/tag-input"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { @@ -322,8 +327,12 @@ export function ActionBlock({ type: "webhook", url: "", method: "POST", - headers: [{ key: "", value: "" }], - secret: "" + headers: [], + authType: "none", + bearerToken: "", + basicCredentials: "", + customHeaderName: "", + customHeaderValue: "" }); } }} @@ -580,26 +589,187 @@ function WebhookActionFields({ )} /> - ( - - {t("alertingWebhookSecret")} - - - - - - )} - /> + {/* Authentication */} +
+
+ +

+ {t("httpDestAuthDescription")} +

+
+ ( + + + + {/* None */} +
+ +
+ +

+ {t("httpDestAuthNoneDescription")} +

+
+
+ + {/* Bearer */} +
+ +
+
+ +

+ {t("httpDestAuthBearerDescription")} +

+
+ {field.value === "bearer" && ( + ( + + + + + + + )} + /> + )} +
+
+ + {/* Basic */} +
+ +
+
+ +

+ {t("httpDestAuthBasicDescription")} +

+
+ {field.value === "basic" && ( + ( + + + + + + + )} + /> + )} +
+
+ + {/* Custom */} +
+ +
+
+ +

+ {t("httpDestAuthCustomDescription")} +

+
+ {field.value === "custom" && ( +
+ ( + + + + + + + )} + /> + ( + + + + + + + )} + /> +
+ )} +
+
+
+
+ +
+ )} + /> +
); diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index 51a95f760..2756ca165 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -34,7 +34,11 @@ export type AlertRuleFormAction = url: string; method: string; headers: { key: string; value: string }[]; - secret: string; + authType: "none" | "bearer" | "basic" | "custom"; + bearerToken: string; + basicCredentials: string; + customHeaderName: string; + customHeaderValue: string; }; export type AlertRuleFormValues = { @@ -95,6 +99,15 @@ export type AlertRuleApiResponse = { webhookUrl: string; enabled: boolean; lastSentAt: number | null; + config: { + authType: string; + bearerToken?: string; + basicCredentials?: string; + customHeaderName?: string; + customHeaderValue?: string; + headers?: { key: string; value: string }[]; + method?: string; + } | null; }[]; }; @@ -155,7 +168,11 @@ export function buildFormSchema(t: (k: string) => string) { value: z.string() }) ), - secret: z.string() + authType: z.enum(["none", "bearer", "basic", "custom"]), + bearerToken: z.string(), + basicCredentials: z.string(), + customHeaderName: z.string(), + customHeaderValue: z.string() }) ]) ) @@ -293,12 +310,19 @@ export function apiResponseToFormValues( // Each webhook action becomes its own form webhook action for (const w of rule.webhookActions) { + const cfg = w.config; actions.push({ type: "webhook", url: w.webhookUrl, - method: "POST", - headers: [{ key: "", value: "" }], - secret: "" + method: cfg?.method ?? "POST", + headers: cfg?.headers?.length + ? cfg.headers + : [{ key: "", value: "" }], + authType: (cfg?.authType as "none" | "bearer" | "basic" | "custom") ?? "none", + bearerToken: cfg?.bearerToken ?? "", + basicCredentials: cfg?.basicCredentials ?? "", + customHeaderName: cfg?.customHeaderName ?? "", + customHeaderValue: cfg?.customHeaderValue ?? "" }); } @@ -352,18 +376,15 @@ export function formValuesToApiPayload( webhookActions.push({ webhookUrl: action.url.trim(), enabled: true, - // Encode any headers / secret as config JSON if present - ...(action.secret.trim() || - action.headers.some((h) => h.key.trim()) - ? { - config: JSON.stringify({ - secret: action.secret.trim() || undefined, - headers: action.headers.filter( - (h) => h.key.trim() - ) - }) - } - : {}) + config: JSON.stringify({ + authType: action.authType, + bearerToken: action.bearerToken || undefined, + basicCredentials: action.basicCredentials || undefined, + customHeaderName: action.customHeaderName || undefined, + customHeaderValue: action.customHeaderValue || undefined, + headers: action.headers.filter((h) => h.key.trim()), + method: action.method + }) }); } } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index dbd1e0bfb..7380bfd66 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -28,7 +28,7 @@ import z from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; -import { StatusHistoryResponse } from "@server/middlewares/statusHistory"; +import { StatusHistoryResponse } from "@server/lib/statusHistory"; export type ProductUpdate = { link: string | null;