From 5d336561a07d070830dcf1913e85fdbc23319324 Mon Sep 17 00:00:00 2001 From: abrugh <49671+abrugh@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:48:57 -0600 Subject: [PATCH 1/2] feat: custom webhook templates and passthrough mode Add 'custom' and 'passthrough' webhook notification formats. Custom templates use {{placeholder}} syntax with variables: subject, body, job_name, status, execution_id, stdout, stderr, timestamp. Passthrough mode sends script stdout directly as the HTTP payload, enabling scripts to craft their own webhook-ready JSON. UI adds template textarea with Discord/Slack/signal-cli presets. Existing formats (slack, teams, pagerduty, discord, generic) unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/bin/controller.rs | 2 +- src/executor/notifications.rs | 178 +++++++++++++++++++++++++++++-- web/js/settings.js | 40 +++++++ web/partials/views/settings.html | 17 ++- 4 files changed, 226 insertions(+), 11 deletions(-) diff --git a/src/bin/controller.rs b/src/bin/controller.rs index 4b21bf5..e474b9f 100644 --- a/src/bin/controller.rs +++ b/src/bin/controller.rs @@ -332,7 +332,7 @@ async fn main() -> Result<(), Box> { chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC") ); kronforce::executor::notifications::send_notification( - &db_notif, &subject, &body, None, + &db_notif, &subject, &body, None, None, ) .await; } diff --git a/src/executor/notifications.rs b/src/executor/notifications.rs index 982a966..2ed66bd 100644 --- a/src/executor/notifications.rs +++ b/src/executor/notifications.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::db::Db; use crate::db::models::{EventSeverity, ExecutionStatus, JobNotificationConfig}; use serde::{Deserialize, Serialize}; @@ -25,17 +27,35 @@ pub struct SmsConfig { pub from_number: Option, } -/// Webhook notification configuration (Slack, Teams, PagerDuty, generic). +/// Webhook notification configuration (Slack, Teams, PagerDuty, generic, custom template). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebhookConfig { pub enabled: bool, pub url: String, - /// Webhook format: "slack", "teams", "pagerduty", "discord", or "generic" (default). + /// Webhook format: "slack", "teams", "pagerduty", "discord", "custom", "passthrough", or "generic" (default). #[serde(default = "default_generic")] pub format: String, /// Optional custom headers (e.g., Authorization). #[serde(default)] - pub headers: std::collections::HashMap, + pub headers: HashMap, + /// Custom JSON template with `{{placeholder}}` variables. Used when format is "custom". + /// Available placeholders: {{subject}}, {{body}}, {{job_name}}, {{status}}, + /// {{execution_id}}, {{stdout}}, {{stderr}}, {{timestamp}}. + #[serde(default)] + pub template: Option, +} + +/// Context passed to webhook template rendering. Contains all available placeholder values. +#[derive(Debug, Clone, Default)] +pub struct WebhookContext { + pub subject: String, + pub body: String, + pub job_name: Option, + pub status: Option, + pub execution_id: Option, + pub stdout: Option, + pub stderr: Option, + pub timestamp: String, } fn default_generic() -> String { @@ -107,12 +127,13 @@ pub fn load_system_alerts(db: &Db) -> SystemAlerts { .unwrap_or_default() } -/// Sends a notification via all enabled channels (email, SMS) to the given or global recipients. +/// Sends a notification via all enabled channels (email, SMS, webhook) to the given or global recipients. pub async fn send_notification( db: &Db, subject: &str, body: &str, recipient_override: Option<&NotificationRecipients>, + webhook_context: Option, ) { let recipients = match recipient_override { Some(r) if !r.emails.is_empty() || !r.phones.is_empty() => r.clone(), @@ -185,8 +206,14 @@ pub async fn send_notification( let subj = subject.to_string(); let bod = body.to_string(); let db_clone = db.clone(); + let ctx = webhook_context.unwrap_or_else(|| WebhookContext { + subject: subj.clone(), + body: bod.clone(), + timestamp: chrono::Utc::now().to_rfc3339(), + ..Default::default() + }); tokio::spawn(async move { - match send_webhook(&webhook_config, &subj, &bod).await { + match send_webhook(&webhook_config, &subj, &bod, Some(&ctx)).await { Ok(_) => { let _ = db_clone.log_event( "notification.sent", @@ -281,7 +308,22 @@ pub async fn notify_execution_complete( emails: r.emails.clone(), phones: r.phones.clone(), }); - send_notification(db, &subject, &body, recipients.as_ref()).await; + let context = WebhookContext { + subject: subject.clone(), + body: body.clone(), + job_name: Some(job_name.to_string()), + status: Some(match exec_status { + ExecutionStatus::Succeeded => "succeeded".to_string(), + ExecutionStatus::Failed => "failed".to_string(), + ExecutionStatus::TimedOut => "timed_out".to_string(), + _ => "completed".to_string(), + }), + execution_id: Some(exec_id_short.to_string()), + stdout: Some(stdout.to_string()), + stderr: Some(stderr.to_string()), + timestamp: chrono::Utc::now().to_rfc3339(), + }; + send_notification(db, &subject, &body, recipients.as_ref(), Some(context)).await; } /// Sends an email to one or more recipients via SMTP. @@ -362,10 +404,41 @@ pub async fn send_sms(config: &SmsConfig, to: &[String], body: &str) -> Result<( Ok(()) } -/// Sends a notification via a webhook (Slack, Teams, PagerDuty, Discord, or generic JSON POST). -pub async fn send_webhook(config: &WebhookConfig, subject: &str, body: &str) -> Result<(), String> { +/// Renders a template string by replacing `{{placeholder}}` markers with values from the context. +fn render_template(template: &str, ctx: &WebhookContext) -> String { + let mut result = template.to_string(); + result = result.replace("{{subject}}", &ctx.subject); + result = result.replace("{{body}}", &ctx.body); + result = result.replace("{{job_name}}", ctx.job_name.as_deref().unwrap_or("")); + result = result.replace("{{status}}", ctx.status.as_deref().unwrap_or("")); + result = result.replace( + "{{execution_id}}", + ctx.execution_id.as_deref().unwrap_or(""), + ); + result = result.replace("{{stdout}}", ctx.stdout.as_deref().unwrap_or("")); + result = result.replace("{{stderr}}", ctx.stderr.as_deref().unwrap_or("")); + result = result.replace("{{timestamp}}", &ctx.timestamp); + result +} + +/// Sends a notification via a webhook (Slack, Teams, PagerDuty, Discord, custom template, or generic JSON POST). +pub async fn send_webhook( + config: &WebhookConfig, + subject: &str, + body: &str, + context: Option<&WebhookContext>, +) -> Result<(), String> { let client = reqwest::Client::new(); + // Build a default context if none provided + let default_ctx = WebhookContext { + subject: subject.to_string(), + body: body.to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + ..Default::default() + }; + let ctx = context.unwrap_or(&default_ctx); + let payload = match config.format.as_str() { "slack" => serde_json::json!({ "text": format!("*{}*\n{}", subject, body), @@ -404,6 +477,33 @@ pub async fn send_webhook(config: &WebhookConfig, subject: &str, body: &str) -> }] }) } + "custom" => { + let template = config + .template + .as_deref() + .unwrap_or(r#"{"text": "{{subject}}\n{{body}}"}"#); + let rendered = render_template(template, ctx); + serde_json::from_str(&rendered).map_err(|e| { + format!("custom template produced invalid JSON: {e}\nRendered: {rendered}") + })? + } + "passthrough" => { + // In passthrough mode, stdout IS the payload. If stdout is valid JSON, send it raw. + // Falls back to generic format if stdout is empty or not valid JSON. + let raw = ctx.stdout.as_deref().unwrap_or("").trim(); + if raw.is_empty() { + serde_json::json!({ + "subject": subject, + "body": body, + "source": "kronforce", + "timestamp": chrono::Utc::now().to_rfc3339(), + }) + } else { + serde_json::from_str(raw).map_err(|e| { + format!("passthrough mode requires stdout to be valid JSON: {e}") + })? + } + } _ => serde_json::json!({ "subject": subject, "body": body, @@ -482,6 +582,7 @@ pub async fn send_test(db: &Db) -> Result { &webhook_config, "[Kronforce] Test Notification", "This is a test notification from Kronforce.", + None, ) .await { @@ -494,3 +595,64 @@ pub async fn send_test(db: &Db) -> Result { Ok(results.join("; ")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_render_template_all_placeholders() { + let ctx = WebhookContext { + subject: "Job 'deploy' succeeded".to_string(), + body: "All good".to_string(), + job_name: Some("deploy".to_string()), + status: Some("succeeded".to_string()), + execution_id: Some("abc123".to_string()), + stdout: Some("deployed v2.0".to_string()), + stderr: Some("".to_string()), + timestamp: "2026-06-08T12:00:00Z".to_string(), + }; + let template = r#"{"title":"{{subject}}","output":"{{stdout}}","job":"{{job_name}}","status":"{{status}}","exec":"{{execution_id}}","ts":"{{timestamp}}"}"#; + let rendered = render_template(template, &ctx); + let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(parsed["title"], "Job 'deploy' succeeded"); + assert_eq!(parsed["output"], "deployed v2.0"); + assert_eq!(parsed["job"], "deploy"); + assert_eq!(parsed["status"], "succeeded"); + assert_eq!(parsed["exec"], "abc123"); + assert_eq!(parsed["ts"], "2026-06-08T12:00:00Z"); + } + + #[test] + fn test_render_template_missing_optional_fields() { + let ctx = WebhookContext { + subject: "test".to_string(), + body: "hello".to_string(), + job_name: None, + status: None, + execution_id: None, + stdout: None, + stderr: None, + timestamp: "now".to_string(), + }; + let template = r#"{"msg":"{{subject}} {{job_name}}"}"#; + let rendered = render_template(template, &ctx); + let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(parsed["msg"], "test "); + } + + #[test] + fn test_render_template_discord_preset() { + let ctx = WebhookContext { + subject: "Build done".to_string(), + body: "Success".to_string(), + timestamp: "2026-01-01T00:00:00Z".to_string(), + ..Default::default() + }; + let template = r#"{"embeds":[{"title":"{{subject}}","description":"{{body}}","timestamp":"{{timestamp}}"}]}"#; + let rendered = render_template(template, &ctx); + let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(parsed["embeds"][0]["title"], "Build done"); + assert_eq!(parsed["embeds"][0]["description"], "Success"); + } +} diff --git a/web/js/settings.js b/web/js/settings.js index 60476c7..68f9c74 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -237,6 +237,8 @@ async function loadNotificationSettings() { document.getElementById('notif-webhook-url').value = webhook.url || ''; document.getElementById('notif-webhook-format').value = webhook.format || 'slack'; document.getElementById('notif-webhook-headers').value = webhook.headers && Object.keys(webhook.headers).length ? JSON.stringify(webhook.headers) : ''; + document.getElementById('notif-webhook-template').value = webhook.template || ''; + toggleWebhookTemplate(); const recipients = settings.notification_recipients ? JSON.parse(settings.notification_recipients) : {}; document.getElementById('notif-emails').value = (recipients.emails || []).join('\n'); @@ -269,11 +271,13 @@ async function saveNotificationSettings() { }); let webhookHeaders = {}; try { const h = document.getElementById('notif-webhook-headers').value.trim(); if (h) webhookHeaders = JSON.parse(h); } catch(e) { /* ignore invalid JSON */ } + const webhookTemplate = document.getElementById('notif-webhook-template').value.trim(); settings.notification_webhook = JSON.stringify({ enabled: document.getElementById('notif-webhook-enabled').checked, url: document.getElementById('notif-webhook-url').value.trim(), format: document.getElementById('notif-webhook-format').value, headers: webhookHeaders, + template: webhookTemplate || null, }); settings.notification_recipients = JSON.stringify({ emails: document.getElementById('notif-emails').value.split('\n').map(s => s.trim()).filter(Boolean), @@ -307,3 +311,39 @@ async function testNotification() { } } +function toggleWebhookTemplate() { + const format = document.getElementById('notif-webhook-format').value; + const wrap = document.getElementById('notif-webhook-template-wrap'); + if (wrap) wrap.style.display = format === 'custom' ? '' : 'none'; +} + +const WEBHOOK_TEMPLATE_PRESETS = { + discord: JSON.stringify({ + embeds: [{ + title: "{{subject}}", + description: "{{body}}", + color: 3066993, + footer: { text: "Kronforce" }, + timestamp: "{{timestamp}}" + }] + }, null, 2), + slack: JSON.stringify({ + text: "*{{subject}}*\n{{body}}" + }, null, 2), + signal: JSON.stringify({ + jsonrpc: "2.0", + method: "send", + params: { + recipient: ["+1234567890"], + message: "{{subject}}\n{{body}}" + }, + id: 1 + }, null, 2), +}; + +function loadTemplatePreset(name) { + const el = document.getElementById('notif-webhook-template'); + if (el && WEBHOOK_TEMPLATE_PRESETS[name]) { + el.value = WEBHOOK_TEMPLATE_PRESETS[name]; + } +} diff --git a/web/partials/views/settings.html b/web/partials/views/settings.html index b48944d..10e3c9c 100644 --- a/web/partials/views/settings.html +++ b/web/partials/views/settings.html @@ -212,18 +212,31 @@

API Keys

- +
- + + +
+
From a121eb8c713b5d555a8ec78f3ac9d69f515d7158 Mon Sep 17 00:00:00 2001 From: Alexander Brugh Date: Thu, 27 Aug 2026 11:59:40 -0600 Subject: [PATCH 2/2] fix(security): bump h2 to 0.4.16 and ignore smartstring unmaintained advisory h2 0.4.14 has an unbounded empty DATA frames vulnerability (fixed in 0.4.16); the bump is lockfile-only and compatible with hyper 1.10.1. smartstring (transitive dep of rhai) was archived upstream with no safe upgrade available (RUSTSEC-2026-0249); ignored with a documented reason and a re-evaluation date until rhai is replaced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 14 +++++++------- deny.toml | 8 ++++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6e4e225..ac24d84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -513,7 +513,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -687,9 +687,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -1812,7 +1812,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1870,7 +1870,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2186,7 +2186,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2780,7 +2780,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/deny.toml b/deny.toml index aafb312..dc4aa91 100644 --- a/deny.toml +++ b/deny.toml @@ -5,6 +5,14 @@ targets = [] db-path = "~/.cargo/advisory-db" db-urls = ["https://github.com/rustsec/advisory-db"] +# smartstring (transitive dep of rhai, used for script jobs) was archived +# upstream on 2026-05-03 with no safe upgrade available (RUSTSEC-2026-0249). +# rhai 1.26.0 still depends on it, so the only fix is replacing rhai. +# Re-evaluate before 2026-12-31. +ignore = [ + { id = "RUSTSEC-2026-0249", reason = "smartstring is a transitive dep of rhai (script jobs); crate archived 2026-05-03 with no safe upgrade — tracked for replacement, re-evaluate 2026-12-31" }, +] + [licenses] allow = [ "MIT",