//! HTTP client for the WAM (Whack-a-Mole) ticket manager. //! //! WAM runs on the tailnet. The MNW server creates tickets for operational //! events that need human attention (stale refunds, dead webhooks, etc.). When //! WAM is configured with a shared token, set `WAM_TOKEN` here to the same value //! and it is presented as a bearer token on every request; without it WAM must //! be running unauthenticated (tailnet-ACL only) or requests are rejected. use serde::Serialize; /// WAM ticket creation client. #[derive(Clone)] pub struct WamClient { http: reqwest::Client, base_url: String, token: Option, } #[derive(Serialize)] struct CreateTicketRequest<'a> { title: &'a str, #[serde(skip_serializing_if = "Option::is_none")] body: Option<&'a str>, priority: &'a str, source: &'a str, #[serde(skip_serializing_if = "Option::is_none")] source_ref: Option<&'a str>, } impl WamClient { /// Build a client for `base_url`. `token`, when `Some`, is sent as a bearer /// token on every request (matching WAM's shared-secret auth). pub fn new(base_url: String, token: Option) -> Self { // `build()` constructs the rustls connector, which reads the // process-wide provider and panics if none is installed. Idempotent. crate::crypto::install_default_crypto_provider(); let http = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) .connect_timeout(std::time::Duration::from_secs(3)) .build() .expect("WAM HTTP client"); let token = token.map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()); Self { http, base_url, token, } } /// Return the ticket endpoint URL. pub fn ticket_url(&self) -> String { format!("{}/tickets", self.base_url.trim_end_matches('/')) } /// Create a ticket in WAM. Errors are logged but never propagated, WAM /// is a best-effort notification channel, not a critical path. #[tracing::instrument(skip_all, fields(source = %source, priority = %priority))] pub async fn create_ticket( &self, title: &str, body: Option<&str>, priority: &str, source: &str, source_ref: Option<&str>, ) { let url = self.ticket_url(); let req = CreateTicketRequest { title, body, priority, source, source_ref, }; let mut request = self.http.post(&url).json(&req); if let Some(token) = &self.token { request = request.bearer_auth(token); } match request.send().await { Ok(resp) if resp.status().is_success() => { tracing::info!(title, source, "WAM ticket created"); } Ok(resp) => { tracing::warn!( status = %resp.status(), title, source, "WAM ticket creation returned non-success" ); } Err(e) => { tracing::warn!(error = %e, title, source, "WAM unreachable"); } } } } #[cfg(test)] mod tests { use super::*; #[test] fn ticket_url_construction() { let client = WamClient::new("http://100.120.174.96:7890".to_string(), None); assert_eq!(client.ticket_url(), "http://100.120.174.96:7890/tickets"); } #[test] fn ticket_url_strips_trailing_slash() { let client = WamClient::new("http://localhost:7890/".to_string(), None); assert_eq!(client.ticket_url(), "http://localhost:7890/tickets"); } #[test] fn request_serialization_full() { let req = CreateTicketRequest { title: "Test ticket", body: Some("Details here"), priority: "high", source: "test-source", source_ref: Some("ref-123"), }; let json = serde_json::to_value(&req).unwrap(); assert_eq!(json["title"], "Test ticket"); assert_eq!(json["body"], "Details here"); assert_eq!(json["priority"], "high"); assert_eq!(json["source"], "test-source"); assert_eq!(json["source_ref"], "ref-123"); } #[test] fn request_serialization_skips_none_fields() { let req = CreateTicketRequest { title: "Minimal", body: None, priority: "low", source: "test", source_ref: None, }; let json = serde_json::to_value(&req).unwrap(); assert_eq!(json["title"], "Minimal"); assert!(json.get("body").is_none()); assert!(json.get("source_ref").is_none()); } #[tokio::test] async fn create_ticket_unreachable_does_not_panic() { // WAM is fire-and-forget, unreachable server should not panic let client = WamClient::new("http://127.0.0.1:1".to_string(), None); client .create_ticket("test", None, "low", "test", None) .await; // If we get here, the error was swallowed correctly } }