feat: add webhook notification system with Settings UI (REMOTE-03)

Webhook module with HTTP delivery, HMAC-SHA256 signing, and event
filtering. RPC handlers for get-config, configure, and test endpoints.
Settings page gains webhook configuration section with URL, secret,
event toggles, and test button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-11 12:55:13 +00:00
parent 67e501e70e
commit 7fc170f50e
7 changed files with 521 additions and 3 deletions

View File

@@ -42,11 +42,13 @@ archipelago-parmanode = { path = "../parmanode" }
# Authentication
bcrypt = "0.15"
sha2 = "0.10"
hmac = "0.12"
uuid = { version = "1.0", features = ["v4"] }
regex = "1.10"
# Node identity (Ed25519)
# Node identity (Ed25519 + X25519 key agreement)
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
curve25519-dalek = "4"
rand = "0.8"
hex = "0.4"
bs58 = "0.5"
@@ -57,7 +59,8 @@ toml = "0.8"
serde_yaml = "0.9"
# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging)
reqwest = { version = "0.11", features = ["json", "socks"] }
# Uses rustls-tls for cross-compilation (no OpenSSL dependency)
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls"] }
# Nostr (node discovery)
nostr-sdk = "0.44"
@@ -67,6 +70,10 @@ argon2 = "0.5"
chacha20poly1305 = "0.10"
base64 = "0.21"
# Full system backup (tar archive + gzip compression)
tar = "0.4"
flate2 = "1.0"
# TOTP 2FA
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
qrcode = "0.14"

View File

@@ -26,6 +26,7 @@ mod system;
mod update;
mod vpn;
mod wallet;
mod webhooks;
use crate::auth::AuthManager;
use crate::config::Config;
@@ -490,6 +491,11 @@ impl RpcHandler {
self.handle_security_list_expiring(&p).await
}
// Webhooks
"webhook.get-config" => self.handle_webhook_get_config().await,
"webhook.configure" => self.handle_webhook_configure(params).await,
"webhook.test" => self.handle_webhook_test().await,
_ => {
Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method))
}

View File

@@ -0,0 +1,83 @@
use super::RpcHandler;
use crate::webhooks;
use anyhow::Result;
use tracing::info;
impl RpcHandler {
/// webhook.get-config — Get current webhook configuration.
pub(super) async fn handle_webhook_get_config(&self) -> Result<serde_json::Value> {
let config = webhooks::load_config(&self.config.data_dir).await?;
Ok(serde_json::json!({
"enabled": config.enabled,
"url": config.url,
"events": config.events,
"has_secret": config.secret.is_some(),
}))
}
/// webhook.configure — Update webhook configuration.
pub(super) async fn handle_webhook_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let mut config = webhooks::load_config(&self.config.data_dir).await?;
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
config.enabled = enabled;
}
if let Some(url) = params.get("url").and_then(|v| v.as_str()) {
config.url = url.to_string();
}
if let Some(secret) = params.get("secret").and_then(|v| v.as_str()) {
config.secret = if secret.is_empty() {
None
} else {
Some(secret.to_string())
};
}
if let Some(events) = params.get("events") {
if let Ok(parsed) = serde_json::from_value::<Vec<webhooks::WebhookEvent>>(events.clone())
{
config.events = parsed;
}
}
webhooks::save_config(&self.config.data_dir, &config).await?;
info!("Webhook config updated: enabled={}, url={}", config.enabled, config.url);
Ok(serde_json::json!({
"configured": true,
"enabled": config.enabled,
"url": config.url,
}))
}
/// webhook.test — Send a test webhook notification.
pub(super) async fn handle_webhook_test(&self) -> Result<serde_json::Value> {
let config = webhooks::load_config(&self.config.data_dir).await?;
if !config.enabled || config.url.is_empty() {
anyhow::bail!("Webhook is not configured. Set a URL and enable it first.");
}
let payload = webhooks::WebhookPayload {
event: webhooks::WebhookEvent::ContainerCrash,
title: "Test Notification".to_string(),
message: "This is a test webhook from your Archipelago node.".to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
node_id: {
let (data, _) = self.state_manager.get_snapshot().await;
data.server_info.id
},
details: Some(serde_json::json!({"test": true})),
};
webhooks::send_webhook(&self.config.data_dir, payload).await;
Ok(serde_json::json!({
"sent": true,
"url": config.url,
}))
}
}

View File

@@ -38,6 +38,7 @@ mod network;
mod nostr_relays;
mod update;
mod vpn;
mod webhooks;
use auth::AuthManager;
use config::Config;

View File

@@ -0,0 +1,227 @@
//! Webhook notification system.
//!
//! Sends HTTP POST notifications to a configured webhook URL when
//! system events occur (container crashes, updates, disk warnings, etc).
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::{debug, warn};
const WEBHOOK_CONFIG_FILE: &str = "webhook-config.json";
/// Events that can trigger webhook notifications.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum WebhookEvent {
ContainerCrash,
UpdateAvailable,
DiskWarning,
BackupComplete,
}
/// Persisted webhook configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookConfig {
pub enabled: bool,
pub url: String,
pub events: Vec<WebhookEvent>,
#[serde(default)]
pub secret: Option<String>,
}
impl Default for WebhookConfig {
fn default() -> Self {
Self {
enabled: false,
url: String::new(),
events: vec![
WebhookEvent::ContainerCrash,
WebhookEvent::UpdateAvailable,
WebhookEvent::DiskWarning,
WebhookEvent::BackupComplete,
],
secret: None,
}
}
}
/// Payload sent to the webhook URL.
#[derive(Debug, Clone, Serialize)]
pub struct WebhookPayload {
pub event: WebhookEvent,
pub title: String,
pub message: String,
pub timestamp: String,
pub node_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
pub async fn load_config(data_dir: &Path) -> Result<WebhookConfig> {
let path = data_dir.join(WEBHOOK_CONFIG_FILE);
if !path.exists() {
return Ok(WebhookConfig::default());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read webhook config")?;
let config: WebhookConfig = serde_json::from_str(&content).unwrap_or_default();
Ok(config)
}
pub async fn save_config(data_dir: &Path, config: &WebhookConfig) -> Result<()> {
fs::create_dir_all(data_dir)
.await
.context("Failed to create data dir")?;
let content =
serde_json::to_string_pretty(config).context("Failed to serialize webhook config")?;
fs::write(data_dir.join(WEBHOOK_CONFIG_FILE), content)
.await
.context("Failed to write webhook config")?;
Ok(())
}
/// Send a webhook notification (non-blocking, fire-and-forget).
pub async fn send_webhook(data_dir: &Path, payload: WebhookPayload) {
let config = match load_config(data_dir).await {
Ok(c) => c,
Err(e) => {
debug!("Webhook config not loaded: {}", e);
return;
}
};
if !config.enabled || config.url.is_empty() {
return;
}
// Check if this event type is subscribed
if !config.events.contains(&payload.event) {
debug!("Webhook event {:?} not subscribed", payload.event);
return;
}
let url = config.url.clone();
let secret = config.secret.clone();
tokio::spawn(async move {
if let Err(e) = send_http_webhook(&url, &payload, secret.as_deref()).await {
warn!("Webhook delivery failed: {}", e);
} else {
debug!("Webhook delivered to {}", url);
}
});
}
async fn send_http_webhook(
url: &str,
payload: &WebhookPayload,
secret: Option<&str>,
) -> Result<()> {
let body = serde_json::to_string(payload).context("Failed to serialize webhook payload")?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("Failed to create HTTP client")?;
let mut request = client
.post(url)
.header("Content-Type", "application/json")
.header("User-Agent", "Archipelago-Webhook/1.0");
// Add HMAC signature if secret is configured
if let Some(secret) = secret {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).context("Invalid HMAC key")?;
mac.update(body.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
request = request.header("X-Webhook-Signature", format!("sha256={}", signature));
}
let response = request
.body(body)
.send()
.await
.context("Failed to send webhook")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("Webhook returned {}: {}", status, text);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = WebhookConfig::default();
assert!(!config.enabled);
assert!(config.url.is_empty());
assert_eq!(config.events.len(), 4);
}
#[test]
fn test_config_serialization() {
let config = WebhookConfig {
enabled: true,
url: "https://hooks.example.com/notify".to_string(),
events: vec![WebhookEvent::ContainerCrash, WebhookEvent::DiskWarning],
secret: Some("my-secret".to_string()),
};
let json = serde_json::to_string(&config).unwrap();
let parsed: WebhookConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.events.len(), 2);
assert_eq!(parsed.secret, Some("my-secret".to_string()));
}
#[test]
fn test_payload_serialization() {
let payload = WebhookPayload {
event: WebhookEvent::ContainerCrash,
title: "Bitcoin stopped".to_string(),
message: "Container bitcoin-knots has stopped unexpectedly".to_string(),
timestamp: "2026-03-11T12:00:00Z".to_string(),
node_id: "test-node-id".to_string(),
details: Some(serde_json::json!({"container": "bitcoin-knots"})),
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("container_crash"));
assert!(json.contains("bitcoin-knots"));
}
#[tokio::test]
async fn test_load_config_default() {
let dir = tempfile::tempdir().unwrap();
let config = load_config(dir.path()).await.unwrap();
assert!(!config.enabled);
}
#[tokio::test]
async fn test_save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = WebhookConfig {
enabled: true,
url: "https://example.com/hook".to_string(),
events: vec![WebhookEvent::BackupComplete],
secret: None,
};
save_config(dir.path(), &config).await.unwrap();
let loaded = load_config(dir.path()).await.unwrap();
assert!(loaded.enabled);
assert_eq!(loaded.url, "https://example.com/hook");
assert_eq!(loaded.events.len(), 1);
}
}