diff --git a/bin/core/src/alert/mod.rs b/bin/core/src/alert/mod.rs index c015c41d0..e97ee3c85 100644 --- a/bin/core/src/alert/mod.rs +++ b/bin/core/src/alert/mod.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use anyhow::{Context, anyhow}; use database::mungos::{find::find_collect, mongodb::bson::doc}; use futures_util::future::join_all; @@ -106,14 +108,17 @@ pub async fn send_alert_to_alerter( } match &alerter.config.endpoint { - AlerterEndpoint::Custom(CustomAlerterEndpoint { url }) => { - send_custom_alert(url, alert).await.with_context(|| { + AlerterEndpoint::Custom(CustomAlerterEndpoint { + url, + headers, + }) => send_custom_alert(url, headers, alert).await.with_context( + || { format!( "Failed to send alert to Custom Alerter {}", alerter.name ) - }) - } + }, + ), AlerterEndpoint::Slack(SlackAlerterEndpoint { url }) => { slack::send_alert(url, alert).await.with_context(|| { format!( @@ -153,33 +158,40 @@ pub async fn send_alert_to_alerter( async fn send_custom_alert( url: &str, + headers: &HashMap, alert: &Alert, ) -> anyhow::Result<()> { let VariablesAndSecrets { variables, secrets } = get_variables_and_secrets().await?; - let mut url_interpolated = url.to_string(); + send_custom_alert_inner(url, headers, alert, &variables, &secrets) + .await +} - let mut interpolator = - Interpolator::new(Some(&variables), &secrets); +async fn send_custom_alert_inner( + url: &str, + headers: &HashMap, + alert: &Alert, + variables: &HashMap, + secrets: &HashMap, +) -> anyhow::Result<()> { + let mut interpolator = Interpolator::new(Some(variables), secrets); + let (url_interpolated, headers) = interpolate_custom_request_parts( + url, + headers, + &mut interpolator, + )?; - interpolator.interpolate_string(&mut url_interpolated)?; + let mut request = + reqwest::Client::new().post(url_interpolated).json(alert); - let res = reqwest::Client::new() - .post(url_interpolated) - .json(alert) + for (header, value) in headers { + request = request.header(&header, &value); + } + + let res = request .send() .await - .map_err(|e| { - let replacers = interpolator - .secret_replacers - .into_iter() - .collect::>(); - let sanitized_error = - svi::replace_in_string(&format!("{e:?}"), &replacers); - anyhow::Error::msg(format!( - "Error with request: {sanitized_error}" - )) - }) + .map_err(|e| sanitize_request_error(&interpolator, &e)) .context("failed at post request to alerter")?; let status = res.status(); if !status.is_success() { @@ -187,6 +199,7 @@ async fn send_custom_alert( .text() .await .context("failed to get response text on alerter response")?; + let text = sanitize_interpolated_text(&interpolator, &text); return Err(anyhow!( "post to alerter failed | {status} | {text}" )); @@ -194,6 +207,51 @@ async fn send_custom_alert( Ok(()) } +fn interpolate_custom_request_parts( + url: &str, + headers: &HashMap, + interpolator: &mut Interpolator, +) -> anyhow::Result<(String, Vec<(String, String)>)> { + let mut url_interpolated = url.to_string(); + interpolator.interpolate_string(&mut url_interpolated)?; + + let headers = headers + .iter() + .map(|(header, value)| { + let mut header = header.to_string(); + let mut value = value.to_string(); + interpolator.interpolate_string(&mut header)?; + interpolator.interpolate_string(&mut value)?; + anyhow::Ok((header, value)) + }) + .collect::>>()?; + let mut headers = headers; + headers.sort_by(|left, right| left.0.cmp(&right.0)); + + Ok((url_interpolated, headers)) +} + +fn sanitize_request_error( + interpolator: &Interpolator, + error: &impl std::fmt::Debug, +) -> anyhow::Error { + let sanitized_error = + sanitize_interpolated_text(interpolator, &format!("{error:?}")); + anyhow::Error::msg(format!("Error with request: {sanitized_error}")) +} + +fn sanitize_interpolated_text( + interpolator: &Interpolator, + text: &str, +) -> String { + let replacers = interpolator + .secret_replacers + .iter() + .cloned() + .collect::>(); + svi::replace_in_string(text, &replacers) +} + fn fmt_region(region: &Option) -> String { match region { Some(region) => format!(" ({region})"), @@ -221,6 +279,105 @@ fn fmt_stack_state(state: &StackState) -> String { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn custom_alert_interpolates_headers_and_url() { + let mut headers = HashMap::new(); + headers.insert( + String::from("authorization"), + String::from("Bearer [[TOKEN]]"), + ); + headers + .insert(String::from("x-komodo-env"), String::from("[[ENV]]")); + + let mut variables = HashMap::new(); + variables.insert(String::from("ENV"), String::from("dev")); + + let mut secrets = HashMap::new(); + secrets + .insert(String::from("TOKEN"), String::from("super-secret")); + + let mut interpolator = + Interpolator::new(Some(&variables), &secrets); + let (url, headers) = interpolate_custom_request_parts( + "https://example.com/[[ENV]]", + &headers, + &mut interpolator, + ) + .unwrap(); + + assert_eq!(url, "https://example.com/dev"); + assert_eq!( + headers, + vec![ + ( + String::from("authorization"), + String::from("Bearer super-secret"), + ), + (String::from("x-komodo-env"), String::from("dev")), + ] + ); + } + + #[test] + fn custom_alert_sanitizes_secret_on_request_error() { + let mut headers = HashMap::new(); + headers.insert( + String::from("authorization"), + String::from("Bearer [[TOKEN]]"), + ); + + let variables = HashMap::new(); + let secrets = HashMap::from([( + String::from("TOKEN"), + String::from("super-secret"), + )]); + let mut interpolator = + Interpolator::new(Some(&variables), &secrets); + + interpolate_custom_request_parts( + "https://example.com", + &headers, + &mut interpolator, + ) + .unwrap(); + + let err = sanitize_request_error( + &interpolator, + &"authorization: Bearer super-secret", + ) + .to_string(); + + assert!(err.contains("Error with request:")); + assert!(!err.contains("super-secret")); + } + + #[test] + fn custom_alert_sanitizes_secret_in_response_body() { + let variables = HashMap::new(); + let secrets = HashMap::from([( + String::from("TOKEN"), + String::from("super-secret"), + )]); + let interpolator = + Interpolator::new(Some(&variables), &secrets); + let mut interpolator = interpolator; + let mut token = String::from("[[TOKEN]]"); + + interpolator.interpolate_string(&mut token).unwrap(); + + let sanitized = sanitize_interpolated_text( + &interpolator, + "authorization: Bearer super-secret", + ); + + assert!(!sanitized.contains("super-secret")); + } +} + fn fmt_level(level: SeverityLevel) -> &'static str { match level { SeverityLevel::Critical => "CRITICAL 🚨", diff --git a/client/core/rs/src/entities/alerter.rs b/client/core/rs/src/entities/alerter.rs index 9f8b9f9f7..44dd58a0d 100644 --- a/client/core/rs/src/entities/alerter.rs +++ b/client/core/rs/src/entities/alerter.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use bson::{Document, doc}; use derive_builder::Builder; use derive_default_builder::DefaultBuilder; @@ -184,12 +186,18 @@ pub struct CustomAlerterEndpoint { #[serde(default = "default_custom_url")] #[builder(default = "default_custom_url()")] pub url: String, + + /// Additional headers to include on the request. + #[serde(default)] + #[builder(default)] + pub headers: HashMap, } impl Default for CustomAlerterEndpoint { fn default() -> Self { Self { url: default_custom_url(), + headers: Default::default(), } } } diff --git a/client/core/ts/src/types.ts b/client/core/ts/src/types.ts index dba43aa9c..8d086c3cb 100644 --- a/client/core/ts/src/types.ts +++ b/client/core/ts/src/types.ts @@ -1113,6 +1113,7 @@ export type UserConfig = description: string; }}; + export type LinkedLoginsMap = Record; export interface UserTotpConfig { @@ -7006,6 +7007,8 @@ export interface CreateVariable { export interface CustomAlerterEndpoint { /** The http/s endpoint to send the POST to */ url: string; + /** Additional headers to include on the request. */ + headers: Record; } /** @@ -11117,4 +11120,3 @@ export type WsLoginMessage = key: string; secret: string; }}; - diff --git a/docsite/docs/resources.md b/docsite/docs/resources.md index 24eb5a479..2b8552af2 100644 --- a/docsite/docs/resources.md +++ b/docsite/docs/resources.md @@ -70,4 +70,5 @@ All resources which depend on git repos / docker registries are able to use thes ## Alerter - Route alerts to various endpoints. +- `Custom` alerters can post the raw JSON alert payload to any HTTP endpoint and include custom request headers. - Can configure rules on each Alerter, such as resource whitelist, blacklist, or alert type filter. diff --git a/ui/public/client/types.d.ts b/ui/public/client/types.d.ts index a25b5291e..f0e4e6a22 100644 --- a/ui/public/client/types.d.ts +++ b/ui/public/client/types.d.ts @@ -6738,6 +6738,8 @@ export interface CreateVariable { export interface CustomAlerterEndpoint { /** The http/s endpoint to send the POST to */ url: string; + /** Additional headers to include on the request. */ + headers: Record; } /** * Deletes the action at the given id, and returns the deleted action. diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 8885953a3..668c4abc6 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -30,6 +30,13 @@ export function envToText(envVars: Types.EnvironmentVar[] | undefined) { ); } +export function recordToText(record: Record | undefined) { + return Object.entries(record ?? {}) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}: ${value}`) + .join("\n"); +} + export function textToEnv(env: string): Types.EnvironmentVar[] { return env .split("\n") @@ -41,6 +48,39 @@ export function textToEnv(env: string): Types.EnvironmentVar[] { .map(([variable, value]) => ({ variable, value })); } +export function textToRecord(text: string): Record { + const record: Record = {}; + + for (const rawLine of text.split("\n")) { + if (!keepLine(rawLine)) continue; + + const line = rawLine + .split(" #", 1)[0] + .trim() + .replace(/^-/, "") + .trim(); + const separator = line.search(/[:=]/); + + if (separator === -1) { + throw new Error("Each header line must contain ':' or '='."); + } + + const key = line.slice(0, separator).trim().replace(/^["']|["']$/g, ""); + const value = line + .slice(separator + 1) + .trim() + .replace(/^["']|["']$/g, ""); + + if (!key) { + throw new Error("Header names cannot be empty."); + } + + record[key] = value; + } + + return record; +} + function keepLine(line: string) { if (line.length === 0) return false; let firstIndex = -1; diff --git a/ui/src/resources/alerter/config/endpoint.tsx b/ui/src/resources/alerter/config/endpoint.tsx index ba2512186..a6662ecea 100644 --- a/ui/src/resources/alerter/config/endpoint.tsx +++ b/ui/src/resources/alerter/config/endpoint.tsx @@ -1,7 +1,9 @@ import { MonacoEditor } from "@/components/monaco"; +import { recordToText, textToRecord } from "@/lib/utils"; import { ConfigInput, ConfigItem } from "@/ui/config/item"; -import { Select } from "@mantine/core"; +import { Select, Text } from "@mantine/core"; import { Types } from "komodo_client"; +import { useEffect, useState } from "react"; const ENDPOINT_TYPES: Types.AlerterEndpoint["type"][] = [ "Custom", @@ -15,11 +17,26 @@ export default function AlerterConfigEndpoint({ endpoint, set, disabled, + onValidationChange, }: { endpoint: Types.AlerterEndpoint; set: (endpoint: Types.AlerterEndpoint) => void; disabled: boolean; + onValidationChange: (error: string | undefined) => void; }) { + const headersValue = + endpoint.type === "Custom" + ? recordToText(endpoint.params.headers) + : ""; + const [headersText, setHeadersText] = useState(headersValue); + const [headersError, setHeadersError] = useState(); + + useEffect(() => { + setHeadersText(headersValue); + setHeadersError(undefined); + onValidationChange(undefined); + }, [endpoint, headersValue, onValidationChange]); + return ( <> + {endpoint.type === "Custom" && ( + + { + setHeadersText(headersText); + try { + const headers = textToRecord(headersText); + setHeadersError(undefined); + onValidationChange(undefined); + set({ + ...endpoint, + params: { ...endpoint.params, headers }, + }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Invalid header format."; + setHeadersError(message); + onValidationChange(message); + } + }} + readOnly={disabled} + /> + {headersError && ( + + {headersError} + + )} + + )} {endpoint.type === "Ntfy" && ( (); const [update, setUpdate] = useLocalStorage>({ key: `alerter-${id}-update-v1`, defaultValue: {}, @@ -25,6 +27,7 @@ export default function AlerterConfig({ id }: { id: string }) { return ( set({ endpoint })} disabled={disabled} + onValidationChange={setEndpointError} /> ), }, diff --git a/ui/src/ui/config/confirm.tsx b/ui/src/ui/config/confirm.tsx index 96a5f3567..30c4a8664 100644 --- a/ui/src/ui/config/confirm.tsx +++ b/ui/src/ui/config/confirm.tsx @@ -35,6 +35,9 @@ export default function ConfirmUpdate({ const [opened, { open, close }] = useDisclosure(); const handleConfirm = async () => { + if (disabled) { + return; + } await onConfirm(); close(); }; @@ -47,7 +50,7 @@ export default function ConfirmUpdate({ }); useCtrlKeyListener("Enter", () => { - if (opened || !openKeyListener) { + if (opened || disabled || !openKeyListener) { return; } open(); @@ -100,6 +103,7 @@ export default function ConfirmUpdate({ }} w={{ base: "100%", xs: 200 }} loading={loading} + disabled={disabled} > Save diff --git a/ui/src/ui/config/index.tsx b/ui/src/ui/config/index.tsx index 751b03d24..163132f68 100644 --- a/ui/src/ui/config/index.tsx +++ b/ui/src/ui/config/index.tsx @@ -50,6 +50,7 @@ export interface ConfigProps { update: Partial; setUpdate: React.Dispatch>>; disabled: boolean; + saveDisabled?: boolean; onSave: () => Promise; titleOther?: ReactNode; disableSidebar?: boolean; @@ -65,6 +66,7 @@ export default function Config({ update, setUpdate, disabled, + saveDisabled, onSave, titleOther, disableSidebar, @@ -175,7 +177,7 @@ export default function Config({ original={original} update={update} onConfirm={onConfirm} - disabled={disabled} + disabled={disabled || !!saveDisabled} fileContentsLanguage={fileContentsLanguage} fullWidth={fullWidth} />