use crate::{ config::Config, protocol::{ModuleResult, iq_error, iq_result}, wire::escape, }; use anyhow::{Context, Result}; use base64::{Engine, engine::general_purpose::STANDARD}; use hmac::{Hmac, Mac}; use sha1::Sha1; use std::time::Instant; pub fn handle( config: &Config, domain: &str, iq: roxmltree::Node<'_, '_>, started: Instant, ) -> Result> { let Some(q) = iq.first_element_child() else { return Ok(None); }; let ns = q.tag_name().namespace().unwrap_or(""); let name = q.tag_name().name(); let modules = config.modules(domain)?; let payload = match (iq.attribute("type"), name, ns) { (Some("get"), "time", "urn:xmpp:time") if modules.contains("time") => Some(format!( "", chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) )), (Some("get"), "query", "jabber:iq:last") if modules.contains("uptime") => Some(format!( "", started.elapsed().as_secs() )), ( Some("get"), "services" | "credentials", "urn:xmpp:extdisco:1" | "urn:xmpp:extdisco:2", ) if modules.contains("turn_external") => { let host = config .option(domain, "turn_external_host") .and_then(|v| v.as_str()) .unwrap_or(domain); let secret = config .option(domain, "turn_external_secret") .and_then(|v| v.as_str()) .context("TURN secret missing")?; let port = config .option(domain, "turn_external_port") .and_then(|v| v.as_u64()) .unwrap_or(3478); let ttl = config .option(domain, "turn_external_ttl") .and_then(|v| v.as_i64()) .unwrap_or(86400); let expires = chrono::Utc::now() + chrono::Duration::seconds(ttl); let username = match config .option(domain, "turn_external_user") .and_then(|v| v.as_str()) { Some(u) => format!("{}:{u}", expires.timestamp()), None => expires.timestamp().to_string(), }; let mut mac = Hmac::::new_from_slice(secret.as_bytes())?; mac.update(username.as_bytes()); let password = STANDARD.encode(mac.finalize().into_bytes()); let tcp = config .option(domain, "turn_external_tcp") .and_then(|v| v.as_bool()) .unwrap_or(false); let mut services = vec![("stun", "udp", port), ("turn", "udp", port)]; if tcp { services.extend([("stun", "tcp", port), ("turn", "tcp", port)]); } if let Some(p) = config .option(domain, "turn_external_tls_port") .and_then(|v| v.as_u64()) { services.push(("turns", "tcp", p)); } let mut xml = format!("<{name} xmlns='{ns}'>"); for (kind, transport, port) in services { if q.attribute("type").is_some_and(|t| t != kind) { continue; } if name == "credentials" && (kind == "stun" || !q.children().any(|s| { s.is_element() && s.tag_name().name() == "service" && s.attribute("host") == Some(host) && s.attribute("type") == Some(kind) && s.attribute("port").is_none_or(|p| p == port.to_string()) })) { continue; } xml.push_str(&format!( ""); } xml.push_str(&format!("")); Some(xml) } ( Some("get"), "catalog", "urn:xmpp:sec-label:catalog:2" | "urn:xmpp:sec-label:catalog:0", ) if modules.contains("seclabels") => { let configured_labels = config.option(domain, "security_labels"); let compatibility_labels; let labels = if let Some(value) = configured_labels.filter(|value| { value.get("policies").is_some() || value.get("default_policy").is_some() }) { compatibility_labels = match policy_catalog_labels(value) { Ok(labels) => labels, Err(_) => { return Ok(Some(ModuleResult::reply(iq_error(iq, "not-acceptable")))); } }; Some(&compatibility_labels) } else { configured_labels }; let title = config .option(domain, "security_catalog_name") .and_then(|v| v.as_str()) .unwrap_or("Default"); let desc = config .option(domain, "security_catalog_desc") .and_then(|v| v.as_str()) .unwrap_or("My labels"); let target = q .attribute("to") .map(|to| format!(" to='{}'", escape(to))) .unwrap_or_default(); let mut xml = format!( "", escape(title), escape(desc) ); if let Some(value) = labels { labels_xml(value, "", None, ns, &mut xml)?; } else { labels_xml( &serde_json::json!([{"name":"Unclassified","label":true,"default":true}]), "", None, ns, &mut xml, )?; } xml.push_str(""); Some(xml) } _ => None, }; Ok(payload.map(|p| ModuleResult::reply(iq_result(iq, &p)))) } /// Catalog-only compatibility for the example configuration's policy schema. /// Selects `default_policy` and exposes its classifications as ordinary labels. /// Policy names confer no authorization, membership, ACL, or message enforcement. /// Unknown fields are rejected rather than silently implying policy support. fn policy_catalog_labels(value: &serde_json::Value) -> Result { use anyhow::ensure; use serde_json::Value; let root = value .as_object() .context("security labels must be an object")?; ensure!( root.keys() .all(|key| matches!(key.as_str(), "default_policy" | "policies")), "unknown policy catalog option" ); let selected = root .get("default_policy") .and_then(Value::as_str) .filter(|name| !name.trim().is_empty()) .context("default policy missing")?; let policies = root .get("policies") .and_then(Value::as_object) .context("policies must be an object")?; ensure!(policies.contains_key(selected), "default policy not found"); let mut selected_labels = None; for (name, policy) in policies { ensure!(!name.trim().is_empty(), "empty policy name"); let policy = policy.as_object().context("policy must be an object")?; ensure!( policy.keys().all(|key| key == "classifications"), "unknown policy option" ); let classifications = policy .get("classifications") .and_then(Value::as_array) .context("classifications must be an array")?; let mut labels = Vec::with_capacity(classifications.len()); for classification in classifications { let mut label = classification .as_object() .context("classification must be an object")? .clone(); ensure!( label.keys().all(|key| matches!( key.as_str(), "name" | "label" | "display" | "color" | "bgcolor" | "default" )), "unknown classification option" ); ensure!( label .get("name") .and_then(Value::as_str) .is_some_and(|name| !name.trim().is_empty()), "classification name missing" ); for key in ["display", "color", "bgcolor"] { ensure!( label.get(key).is_none_or(Value::is_string), "classification display option must be a string" ); } ensure!( label.get("default").is_none_or(Value::is_boolean), "classification default must be a boolean" ); match label.entry("label").or_insert(Value::Bool(true)) { Value::Bool(true) => {} Value::String(text) => { if text.starts_with('<') { roxmltree::Document::parse(text)?; } } _ => anyhow::bail!("classification label must be true or a string"), } labels.push(Value::Object(label)); } if name == selected { selected_labels = Some(Value::Array(labels)); } } selected_labels.context("default policy not found") } fn labels_xml( v: &serde_json::Value, prefix: &str, key: Option<&str>, ns: &str, out: &mut String, ) -> Result<()> { if let Some(label) = v .get("label") .filter(|v| !v.is_null() && v != &&serde_json::Value::Bool(false)) { let name = key .or_else(|| v.get("name").and_then(|v| v.as_str())) .context("security label name missing")?; let selector = escape(&format!("{prefix}{name}")); let default = if v.get("default") == Some(&serde_json::Value::Bool(true)) { " default='true'" } else { "" }; if ns.ends_with(":2") { out.push_str(&format!( "" )); } else { out.push_str(&format!( "" )); } if v.get("display").is_some() || v.get("color").is_some() || v.get("bgcolor").is_some() { out.push_str("{}", escape(v.get("display").and_then(|v| v.as_str()).unwrap_or(name)) )); } let text = if label == &serde_json::Value::Bool(true) { escape(name) } else if let Some(s) = label.as_str() { if s.starts_with('<') { roxmltree::Document::parse(s)?; s.to_owned() } else { escape(s) } } else { anyhow::bail!("unsupported security label") }; out.push_str(&format!("")); if ns.ends_with(":2") { out.push_str(""); } } else if let Some(a) = v.as_array() { for child in a { labels_xml(child, prefix, None, ns, out)?; } } else if let Some(m) = v.as_object() { let next = key.map(|k| format!("{prefix}{k}|")); for (k, child) in m { labels_xml(child, next.as_deref().unwrap_or(prefix), Some(k), ns, out)?; } } Ok(()) }