Files
rust-xmpp/src/extras.rs
T
2026-10-20 15:59:55 +07:00

322 lines
12 KiB
Rust

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<Option<ModuleResult>> {
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!(
"<time xmlns='urn:xmpp:time'><tzo>+00:00</tzo><utc>{}</utc></time>",
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
)),
(Some("get"), "query", "jabber:iq:last") if modules.contains("uptime") => Some(format!(
"<query xmlns='jabber:iq:last' seconds='{}'/>",
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::<Sha1>::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!(
"<service type='{kind}' transport='{transport}' host='{}' port='{port}'",
escape(host)
));
if kind != "stun" {
xml.push_str(&format!(
" username='{}' password='{}' restricted='1' expires='{}'",
escape(&username),
password,
expires.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
));
}
xml.push_str("/>");
}
xml.push_str(&format!("</{name}>"));
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!(
"<catalog xmlns='{ns}'{target} name='{}' desc='{}'>",
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("</catalog>");
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<serde_json::Value> {
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!(
"<item selector='{selector}'{default}><securitylabel xmlns='urn:xmpp:sec-label:0'>"
));
} else {
out.push_str(&format!(
"<securitylabel xmlns='urn:xmpp:sec-label:0' selector='{selector}'{default}>"
));
}
if v.get("display").is_some() || v.get("color").is_some() || v.get("bgcolor").is_some() {
out.push_str("<displaymarking");
for (k, a) in [("color", "fgcolor"), ("bgcolor", "bgcolor")] {
if let Some(s) = v.get(k).and_then(|v| v.as_str()) {
out.push_str(&format!(" {a}='{}'", escape(s)));
}
}
out.push_str(&format!(
">{}</displaymarking>",
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!("<label>{text}</label></securitylabel>"));
if ns.ends_with(":2") {
out.push_str("</item>");
}
} 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(())
}