initial commit

This commit is contained in:
wt committed 2026-10-20 15:59:55 +07:00
commit c260d7bab8
235 files changed
+49786

No files matched your search

File diff suppressed because it is too large. Load diff
+652
View File
@@ -0,0 +1,652 @@
//! Prosody adminstream wire protocol with a deliberately explicit command surface.
//! Unsupported Lua expressions and commands return errors instead of pretending success.
use crate::{
auth,
wire::{Framer, escape},
};
use anyhow::{Context, Result, bail, ensure};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use tokio::{
io::{AsyncRead, AsyncWrite, AsyncWriteExt},
net::{UnixListener, UnixStream},
sync::Semaphore,
time::timeout,
};
const NS: &str = "xmpp:prosody.im/admin";
const STREAM: &str = "http://etherx.jabber.org/streams";
const HEADER: &str =
"<stream:stream xmlns='xmpp:prosody.im/admin' xmlns:stream='http://etherx.jabber.org/streams'>";
#[derive(Clone, Debug, Default)]
pub struct AdminSnapshot {
pub uptime: Duration,
pub sessions: Vec<String>,
}
#[derive(Clone, Debug, Default)]
pub struct AdminReply {
pub output: Vec<String>,
pub message: String,
pub error: bool,
}
pub type UserMutation = Arc<dyn Fn(&str) -> Result<()> + Send + Sync>;
#[derive(Clone)]
pub struct AdminBackend {
pub data: PathBuf,
/// Authentication domains and their password_hash setting.
pub hosts: BTreeMap<String, String>,
pub modules: BTreeMap<String, Vec<String>>,
pub snapshot: Arc<dyn Fn() -> AdminSnapshot + Send + Sync>,
pub delete_user: Option<UserMutation>,
/// Invoked after disabling an account, to disconnect its sessions.
pub disconnect_user: Option<UserMutation>,
}
#[derive(Clone, Debug)]
struct Command {
section: String,
name: String,
args: Vec<Option<String>>,
}
fn strings(expression: &str) -> Result<Vec<Option<String>>> {
let lua = crate::lua::bounded_vm().map_err(|e| anyhow::anyhow!(e.to_string()))?;
let env = lua
.create_table()
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let table: mlua::Table = lua
.load(format!("return {expression}"))
.set_environment(env)
.eval()
.map_err(|_| anyhow::anyhow!("expected string arguments"))?;
let mut max_index = 0usize;
for pair in table.clone().pairs::<mlua::Value, mlua::Value>() {
let (key, value) = pair.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let mlua::Value::Integer(index) = key else {
bail!("arguments must be an array")
};
ensure!((1..=16).contains(&index), "too many arguments");
ensure!(
matches!(value, mlua::Value::String(_) | mlua::Value::Nil),
"only string arguments are supported"
);
max_index = max_index.max(index as usize);
}
let mut result = vec![];
for index in 1..=max_index {
let value: mlua::Value = table
.raw_get(index)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
result.push(match value {
mlua::Value::String(s) => Some(
s.to_str()
.map_err(|e| anyhow::anyhow!(e.to_string()))?
.to_string(),
),
mlua::Value::Nil => None,
_ => bail!("only string arguments are supported"),
});
}
Ok(result)
}
fn parse(line: &str) -> Result<Command> {
ensure!(line.len() <= 16384, "command too long");
let line = line.trim().trim_end_matches(';').trim();
if line == "help" {
return Ok(Command {
section: "help".into(),
name: "help".into(),
args: vec![],
});
}
if line.starts_with('{') {
let mut args = strings(line)?;
ensure!(args.len() >= 2, "expected command section and name");
let section = args.remove(0).context("missing section")?;
let name = args.remove(0).context("missing command")?;
return Ok(Command {
section,
name,
args,
});
}
let (section, tail) = line
.split_once(':')
.context("expected section:command(arguments)")?;
let (name, tail) = tail.split_once('(').context("missing command arguments")?;
let args = tail
.strip_suffix(')')
.context("missing closing parenthesis")?;
ensure!(
[section, name]
.iter()
.all(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')),
"invalid command name"
);
Ok(Command {
section: section.into(),
name: name.into(),
args: strings(&format!("{{{args}}}"))?,
})
}
impl AdminBackend {
fn account<'a>(&self, jid: &'a str) -> Result<(&'a str, &'a str, &str)> {
ensure!(!jid.contains('/'), "expected bare JID");
let (user, host) = jid.split_once('@').context("expected user@host")?;
auth::user_name(user)?;
let hash = self
.hosts
.get(host)
.context("No such authentication host")?;
Ok((user, host, hash))
}
fn execute(&self, c: &Command) -> Result<AdminReply> {
let arg = |i: usize| c.args.get(i).and_then(|v| v.as_deref());
let mut out = AdminReply::default();
match (c.section.as_str(), c.name.as_str()) {
("help", _) => {
out.output=vec!["server:version(), server:uptime(), server:status()".into(),"c2s:count(), c2s:show([jid]), host:list(), module:list([host])".into(),"user:create(jid[,password]), user:password(jid[,password]), user:delete(jid), user:disable(jid), user:enable(jid)".into()];
out.message="Only listed commands are implemented; arbitrary Lua and module hot reload are unsupported".into();
}
("server", "version") => {
ensure!(c.args.is_empty(), "unexpected arguments");
out.message = format!("rust-xmpp {}", env!("CARGO_PKG_VERSION"));
}
("server", "status") => {
ensure!(c.args.is_empty(), "unexpected arguments");
out.message = "running".into();
}
("server", "uptime") => {
ensure!(c.args.is_empty(), "unexpected arguments");
out.message = format!(
"This server has been running for {} seconds",
(self.snapshot)().uptime.as_secs()
);
}
("c2s", "count") => {
ensure!(c.args.is_empty(), "unexpected arguments");
out.message = format!("Total: {} clients", (self.snapshot)().sessions.len());
}
("c2s", "show") => {
ensure!(
c.args.len() <= 1,
"custom column specification is unsupported"
);
out.output = (self.snapshot)()
.sessions
.into_iter()
.filter(|j| {
arg(0).is_none_or(|a| a == "*" || a == j || a == crate::protocol::bare(j))
})
.collect();
out.output.sort();
out.message = format!("{} c2s sessions shown", out.output.len());
}
("host", "list") => {
ensure!(c.args.is_empty(), "unexpected arguments");
out.output = self.modules.keys().cloned().collect();
out.message = format!("{} hosts", out.output.len());
}
("module", "list") => {
ensure!(c.args.len() <= 1, "unexpected arguments");
if let Some(host) = arg(0) {
let modules = self.modules.get(host).context("No such host")?;
out.output = modules.clone();
} else {
for (host, modules) in &self.modules {
out.output.push(format!("{host}: {}", modules.join(", ")));
}
}
out.message = "Modules listed".into();
}
("user", "create" | "password") => {
ensure!(
c.args.len() == 2,
"expected JID and password; custom roles are unsupported"
);
let jid = arg(0).context("missing JID")?;
let password = arg(1).context("missing password")?;
let (user, host, hash) = self.account(jid)?;
if c.name == "create" {
auth::create(&self.data, host, user, password, hash)?;
out.message = format!("Created {jid}");
} else {
auth::set_password(&self.data, host, user, password, hash)?;
out.message = "User password changed".into();
}
}
("user", "delete") => {
ensure!(c.args.len() == 1, "expected JID");
let jid = arg(0).context("missing JID")?;
self.account(jid)?;
let delete = self.delete_user.as_ref().context(
"user:delete is unsupported: no complete deletion backend is configured",
)?;
delete(jid)?;
out.message = "User deleted".into();
}
("user", "disable" | "enable") => {
ensure!(c.args.len() == 1, "expected JID");
let jid = arg(0).context("missing JID")?;
let (user, host, _) = self.account(jid)?;
ensure!(
crate::storage::load(&self.data, host, Some(user), "accounts")?.is_some(),
"No such user"
);
crate::storage::update(&self.data, host, Some(user), "accounts", |v| {
v.set(
"disabled",
crate::storage::LuaValue::Bool(c.name == "disable"),
);
Ok(())
})?;
if c.name == "disable"
&& let Some(disconnect) = &self.disconnect_user
{
disconnect(jid)
.context("Account disabled, but disconnecting sessions failed")?;
}
out.message = if c.name == "disable" {
"User disabled"
} else {
"User enabled"
}
.into();
}
_ => bail!(
"Unsupported command {}:{}; type help for implemented commands",
c.section,
c.name
),
}
Ok(out)
}
pub fn execute_line(&self, line: &str) -> Result<AdminReply> {
self.execute(&parse(line)?)
}
}
pub struct AdminServer {
listener: UnixListener,
path: PathBuf,
inode: u64,
backend: AdminBackend,
uid: u32,
}
impl AdminServer {
pub fn bind(path: &Path, backend: AdminBackend) -> Result<Self> {
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
if let Ok(meta) = fs::symlink_metadata(path) {
ensure!(
meta.file_type().is_socket(),
"admin path exists and is not a socket"
);
match std::os::unix::net::UnixStream::connect(path) {
Ok(_) => bail!("admin socket is already in use"),
Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => {
fs::remove_file(path)?
}
Err(e) => return Err(e.into()),
}
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let socket = socket2::Socket::new(socket2::Domain::UNIX, socket2::Type::STREAM, None)
.context("create admin Unix socket")?;
socket
.bind(&socket2::SockAddr::unix(path)?)
.context("bind admin Unix socket")?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
socket.listen(32).context("listen admin Unix socket")?;
socket.set_nonblocking(true)?;
let listener = UnixListener::from_std(socket.into())?;
let meta = fs::metadata(path)?;
Ok(Self {
listener,
path: path.into(),
inode: meta.ino(),
uid: meta.uid(),
backend,
})
}
pub async fn serve(self) -> Result<()> {
let semaphore = Arc::new(Semaphore::new(32));
loop {
let (stream, _) = self.listener.accept().await?;
let creds = stream.peer_cred()?;
if creds.uid() != self.uid && creds.uid() != 0 {
continue;
}
let Ok(permit) = semaphore.clone().try_acquire_owned() else {
continue;
};
let backend = self.backend.clone();
tokio::spawn(async move {
let _permit = permit;
let _ = connection(stream, backend).await;
});
}
}
}
impl Drop for AdminServer {
fn drop(&mut self) {
use std::os::unix::fs::MetadataExt;
if fs::symlink_metadata(&self.path).is_ok_and(|m| m.ino() == self.inode) {
let _ = fs::remove_file(&self.path);
}
}
}
async fn send<S: AsyncWrite + Unpin>(s: &mut S, xml: &str) -> Result<()> {
timeout(Duration::from_secs(15), s.write_all(xml.as_bytes())).await??;
Ok(())
}
async fn read<S: AsyncRead + Unpin>(s: &mut S, f: &mut Framer) -> Result<String> {
let text = timeout(Duration::from_secs(300), f.next(s)).await??;
if text.starts_with("<?xml")
|| f.is_stream_end(&text)
|| crate::wire::StreamHeader::parse(&text).is_ok()
{
return Ok(text);
}
let w = f.wrap(&text);
let d = roxmltree::Document::parse(&w)?;
let n = d
.root_element()
.first_element_child()
.context("missing admin frame")?;
Ok(crate::protocol::xml(n))
}
fn wrapped(xml: &str) -> String {
format!("<root xmlns='{NS}' xmlns:stream='{STREAM}'>{xml}</root>")
}
fn header_valid(text: &str) -> Result<()> {
let header = crate::wire::StreamHeader::parse(text)?;
ensure!(
header.default_namespace() == Some(NS),
"invalid admin stream namespace"
);
Ok(())
}
async fn connection<S: AsyncRead + AsyncWrite + Unpin>(
mut stream: S,
backend: AdminBackend,
) -> Result<()> {
let mut f = Framer::default();
let mut opening = read(&mut stream, &mut f).await?;
if opening.starts_with("<?xml ") {
opening = read(&mut stream, &mut f).await?;
}
header_valid(&opening)?;
send(&mut stream, HEADER).await?;
loop {
let text = read(&mut stream, &mut f).await?;
if f.is_stream_end(&text) {
send(&mut stream, "</stream:stream>").await?;
return Ok(());
}
let command = (|| -> Result<Command> {
let w = wrapped(&text);
let d = roxmltree::Document::parse(&w)?;
let n = d
.root_element()
.first_element_child()
.context("empty request")?;
ensure!(
n.tag_name().namespace() == Some(NS) && n.tag_name().name() == "repl-input",
"expected repl-input"
);
ensure!(
!n.children().any(|n| n.is_element()),
"unexpected command child"
);
parse(n.text().unwrap_or(""))
})();
let result = if let Ok(mut command) = command {
if command.section == "user"
&& matches!(command.name.as_str(), "create" | "password")
&& command.args.len() == 1
{
let id = uuid::Uuid::new_v4().simple().to_string();
send(
&mut stream,
&format!("<repl-request-input type='password' id='{id}'/>"),
)
.await?;
let reply = read(&mut stream, &mut f).await?;
let password = (|| -> Result<String> {
let w = wrapped(&reply);
let d = roxmltree::Document::parse(&w)?;
let n = d
.root_element()
.first_element_child()
.context("missing requested input")?;
ensure!(
n.tag_name().namespace() == Some(NS)
&& n.tag_name().name() == "repl-requested-input"
&& n.attribute("id") == Some(id.as_str()),
"unexpected requested input"
);
ensure!(
n.attribute("status").unwrap_or("submit") == "submit",
"password input cancelled"
);
Ok(n.text().unwrap_or("").into())
})();
match password {
Ok(password) => {
command.args.push(Some(password));
let b = backend.clone();
tokio::task::spawn_blocking(move || b.execute(&command)).await?
}
Err(e) => Err(e),
}
} else {
let b = backend.clone();
tokio::task::spawn_blocking(move || b.execute(&command)).await?
}
} else {
Err(command.err().unwrap())
};
match result {
Ok(reply) => {
for line in reply.output {
send(
&mut stream,
&format!("<repl-output>{}</repl-output>", escape(&line)),
)
.await?;
}
send(
&mut stream,
&format!("<repl-result>OK: {}</repl-result>", escape(&reply.message)),
)
.await?;
}
Err(e) => {
send(
&mut stream,
&format!(
"<repl-result type='error'>Error: {}</repl-result>",
escape(&e.to_string())
),
)
.await?
}
}
}
}
/// One command over the real admin socket. Used by CLI and health checks.
pub async fn query(path: &Path, line: &str, password: Option<&str>) -> Result<AdminReply> {
let mut s = timeout(Duration::from_secs(5), UnixStream::connect(path)).await??;
query_stream(&mut s, line, password).await
}
async fn query_stream<S: AsyncRead + AsyncWrite + Unpin>(
mut s: &mut S,
line: &str,
password: Option<&str>,
) -> Result<AdminReply> {
let mut f = Framer::default();
send(&mut s, HEADER).await?;
header_valid(&read(&mut s, &mut f).await?)?;
send(
&mut s,
&format!("<repl-input repl='0'>{}</repl-input>", escape(line)),
)
.await?;
let mut reply = AdminReply::default();
loop {
let text = read(&mut s, &mut f).await?;
let w = wrapped(&text);
let d = roxmltree::Document::parse(&w)?;
let n = d
.root_element()
.first_element_child()
.context("empty admin response")?;
ensure!(
n.tag_name().namespace() == Some(NS),
"invalid admin response namespace"
);
match n.tag_name().name() {
"repl-output" => reply.output.push(n.text().unwrap_or("").into()),
"repl-result" => {
reply.message = n.text().unwrap_or("").into();
reply.error = n.attribute("type") == Some("error");
let _ = send(&mut s, "</stream:stream>").await;
return Ok(reply);
}
"repl-request-input" => {
let id = n.attribute("id").context("missing request ID")?;
ensure!(
n.attribute("type") == Some("password"),
"unsupported input request"
);
let status = if password.is_some() {
"submit"
} else {
"cancel"
};
send(
&mut s,
&format!(
"<repl-requested-input id='{}' status='{status}'>{}</repl-requested-input>",
escape(id),
escape(password.unwrap_or(""))
),
)
.await?;
}
_ => bail!("unexpected admin response"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn backend(path: &Path) -> AdminBackend {
AdminBackend {
data: path.into(),
hosts: BTreeMap::from([("example.com".into(), "SHA-256".into())]),
modules: BTreeMap::from([(
"example.com".into(),
vec!["admin_shell".into(), "tls".into()],
)]),
snapshot: Arc::new(|| AdminSnapshot {
uptime: Duration::from_secs(123),
sessions: vec!["alice@example.com/r".into()],
}),
delete_user: None,
disconnect_user: None,
}
}
async fn run(backend: AdminBackend, line: &str, password: Option<&str>) -> AdminReply {
let (mut client, server) = tokio::io::duplex(65536);
let task = tokio::spawn(connection(server, backend));
let result = query_stream(&mut client, line, password).await.unwrap();
drop(client);
let _ = task.await;
result
}
#[tokio::test]
async fn real_admin_xml_protocol_over_duplex() {
let root = tempfile::tempdir().unwrap();
let b = backend(root.path());
assert_eq!(
run(b.clone(), "server:status()", None).await.message,
"OK: running"
);
assert_eq!(
run(b.clone(), "{\"c2s\",\"count\"}", None).await.message,
"OK: Total: 1 clients"
);
assert_eq!(
run(b.clone(), "c2s:show()", None).await.output,
vec!["alice@example.com/r"]
);
assert!(
!run(
b.clone(),
"user:create('alice@example.com')",
Some("secret<&password")
)
.await
.error
);
assert!(
auth::verify(
root.path(),
"example.com",
"alice",
"secret<&password",
"SHA-256"
)
.unwrap()
);
assert!(
!run(
b.clone(),
"user:password('alice@example.com','changed')",
None
)
.await
.error
);
assert!(auth::verify(root.path(), "example.com", "alice", "changed", "SHA-256").unwrap());
assert!(
!run(b.clone(), "user:disable('alice@example.com')", None)
.await
.error
);
assert!(!auth::verify(root.path(), "example.com", "alice", "changed", "SHA-256").unwrap());
assert!(
!run(b.clone(), "user:enable('alice@example.com')", None)
.await
.error
);
assert!(auth::verify(root.path(), "example.com", "alice", "changed", "SHA-256").unwrap());
for line in [
"module:reload('tls')",
"os.execute('echo forbidden')",
"user:delete('alice@example.com')",
"user:create('bob@example.com',nil,'prosody:admin')",
] {
assert!(run(b.clone(), line, None).await.error, "{line}");
}
assert!(
run(b.clone(), "user:password('alice@example.com')", None)
.await
.error
);
assert!(auth::verify(root.path(), "example.com", "alice", "changed", "SHA-256").unwrap());
let (mut client, server) = tokio::io::duplex(65536);
let task = tokio::spawn(connection(server, b));
client.write_all(b"<a:stream xmlns:a='http://etherx.jabber.org/streams' xmlns='xmpp:prosody.im/admin' xmlns:q='xmpp:prosody.im/admin'><q:repl-input>server:version()</q:repl-input>").await.unwrap();
let mut f = Framer::default();
f.next(&mut client).await.unwrap();
assert!(f.next(&mut client).await.unwrap().contains("rust-xmpp"));
drop(client);
let _ = task.await;
}
}
+542
View File
@@ -0,0 +1,542 @@
//! Prosody internal archives: Lua `.list` records; binary `.lidx` is a rebuildable cache.
use crate::{
storage::{self, LuaValue as V},
wire::escape,
};
use anyhow::{Result, anyhow, bail};
use std::{
io::{BufRead, BufReader, Read, Write},
path::Path,
sync::{Arc, Mutex, OnceLock, Weak},
};
type LockMap = std::collections::HashMap<std::path::PathBuf, Weak<Mutex<()>>>;
static LIFECYCLE: std::sync::RwLock<()> = std::sync::RwLock::new(());
/// Account purge barrier. Callback must not invoke archive methods.
pub fn with_exclusive_access<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
let _guard = LIFECYCLE
.write()
.map_err(|_| anyhow!("archive lifecycle lock poisoned"))?;
f()
}
static LOCKS: OnceLock<Mutex<LockMap>> = OnceLock::new();
fn archive_lock(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
) -> Result<Arc<Mutex<()>>> {
let path = storage::path(root, host, user, store);
let key = if path.is_absolute() {
path
} else {
std::env::current_dir()?.join(path)
};
let mut locks = LOCKS
.get_or_init(|| Mutex::new(LockMap::new()))
.lock()
.map_err(|_| anyhow!("archive lock map poisoned"))?;
if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
return Ok(lock);
}
if locks.len() > 1024 {
locks.retain(|_, v| v.strong_count() > 0);
}
let lock = Arc::new(Mutex::new(()));
locks.insert(key, Arc::downgrade(&lock));
Ok(lock)
}
#[derive(Clone, Debug)]
pub struct Entry {
pub id: String,
pub when: f64,
pub peer: String,
pub xml: String,
}
fn string(v: &V) -> Result<String> {
if let V::String(s) = v {
Ok(String::from_utf8(s.clone())?)
} else {
bail!("expected archive string")
}
}
fn s(x: &str) -> V {
V::String(x.as_bytes().to_vec())
}
fn table() -> V {
V::Table(vec![])
}
pub fn timestamp(value: f64) -> Result<String> {
let seconds = value.floor() as i64;
let nanos = ((value - seconds as f64) * 1e9).round() as u32;
Ok(
chrono::DateTime::from_timestamp(seconds, nanos.min(999999999))
.ok_or_else(|| anyhow!("invalid timestamp"))?
.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true),
)
}
pub fn parse_time(value: &str) -> Result<f64> {
let t = chrono::DateTime::parse_from_rfc3339(value)?;
Ok(t.timestamp() as f64 + t.timestamp_subsec_nanos() as f64 / 1e9)
}
fn from_xml(n: roxmltree::Node<'_, '_>) -> Result<V> {
let mut v = table();
v.set("name", s(n.tag_name().name()));
let mut attr = table();
if let Some(ns) = n.tag_name().namespace() {
attr.set("xmlns", s(ns));
}
for a in n.attributes() {
let key = if let Some(ns) = a.namespace() {
if ns == "http://www.w3.org/XML/1998/namespace" {
format!("xml:{}", a.name())
} else {
format!("{}\u{1}{}", ns, a.name())
}
} else {
a.name().into()
};
attr.set(&key, s(a.value()));
}
v.set("attr", attr);
let mut idx = 1;
for c in n.children() {
let child = if c.is_element() {
from_xml(c)?
} else if c.is_text() {
s(c.text().unwrap_or(""))
} else {
continue;
};
v.set_key(V::Integer(idx), child);
idx += 1;
}
Ok(v)
}
fn to_xml(v: &V, root: bool) -> Result<String> {
if matches!(v, V::String(_)) {
return Ok(escape(&string(v)?));
}
let name = string(
v.get("name")
.ok_or_else(|| anyhow!("archive stanza missing name"))?,
)?;
// Parse constructed XML below, preventing malformed names or injected markup.
let mut out = format!("<{name}");
let mut ns_idx = 0;
if let Some(V::Table(attrs)) = v.get("attr") {
for (k, val) in attrs {
let k = string(k)?;
if root && (k == "stamp" || k == "stamp_legacy") {
continue;
}
let value = escape(&string(val)?);
if let Some((ns, key)) = k.split_once('\u{1}').or_else(|| k.split_once('|')) {
if ns == "http://www.w3.org/XML/1998/namespace" {
out.push_str(&format!(" xml:{key}='{value}'"));
} else {
ns_idx += 1;
out.push_str(&format!(
" xmlns:a{ns_idx}='{}' a{ns_idx}:{key}='{value}'",
escape(ns)
));
}
} else {
out.push_str(&format!(" {k}='{value}'"));
}
}
}
out.push('>');
let mut i = 1;
while let Some(child) = v.get_key(&V::Integer(i)) {
out.push_str(&to_xml(child, false)?);
i += 1;
}
out.push_str(&format!("</{name}>"));
Ok(out)
}
pub fn load(root: &Path, host: &str, user: &str) -> Result<Vec<Entry>> {
load_store(root, host, Some(user), "archive")
}
/// Scan one consistent archive snapshot with memory bounded by one record.
/// Prosody itself indexes record boundaries by lines beginning with `item`.
pub fn scan_store<F: FnMut(Entry) -> Result<()>>(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
visit: F,
) -> Result<()> {
let _life = LIFECYCLE
.read()
.map_err(|_| anyhow!("archive lifecycle lock poisoned"))?;
let lock = archive_lock(root, host, user, store)?;
let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?;
scan_unlocked(root, host, user, store, visit)
}
fn recover_append(p: &Path) -> Result<()> {
let journal = p.with_extension("rappend");
match std::fs::read(&journal) {
Ok(data) => {
anyhow::ensure!(data.len() == 8, "invalid archive append journal");
let length = u64::from_le_bytes(data.try_into().unwrap());
let f = std::fs::OpenOptions::new().write(true).open(p)?;
anyhow::ensure!(
f.metadata()?.len() >= length,
"archive shorter than recovery offset"
);
f.set_len(length)?;
f.sync_all()?;
std::fs::remove_file(journal)?;
std::fs::File::open(p.parent().unwrap())?.sync_all()?;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => return Err(e.into()),
}
Ok(())
}
fn scan_unlocked<F: FnMut(Entry) -> Result<()>>(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
mut visit: F,
) -> Result<()> {
let path = storage::path(root, host, user, store).with_extension("list");
recover_append(&path)?;
let file = match std::fs::File::open(&path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
};
let mut reader = BufReader::new(file);
let mut line = Vec::new();
let mut record = Vec::new();
let lua = crate::lua::bounded_vm().map_err(|e| anyhow!(e.to_string()))?;
let env = lua.create_table().map_err(|e| anyhow!(e.to_string()))?;
let mut decode = |data: &[u8]| -> Result<()> {
if data.iter().all(u8::is_ascii_whitespace) {
return Ok(());
}
let count = std::rc::Rc::new(std::cell::Cell::new(0));
lua.set_hook(
mlua::HookTriggers::new().every_nth_instruction(1000),
move |_, _| {
count.set(count.get() + 1);
if count.get() > 2000 {
return Err(mlua::Error::runtime("archive record instruction limit"));
}
Ok(mlua::VmState::Continue)
},
)
.map_err(|e| anyhow!(e.to_string()))?;
let mut chunk = b"local function item(v) return v end; return ".to_vec();
chunk.extend(data);
let value = lua
.load(&chunk)
.set_mode(mlua::chunk::ChunkMode::Text)
.set_environment(env.clone())
.eval::<mlua::Value>()
.map_err(|e| anyhow!(e.to_string()))?;
visit(decode_entry(&storage::from_lua(value)?)?)?;
lua.gc_collect().map_err(|e| anyhow!(e.to_string()))?;
Ok(())
};
loop {
line.clear();
let n = (&mut reader)
.take(16 * 1024 * 1024 + 1)
.read_until(b'\n', &mut line)?;
if n == 0 {
break;
}
anyhow::ensure!(
line.len() <= 16 * 1024 * 1024,
"archive record line exceeds 16 MiB"
);
if line.starts_with(b"item") && !record.is_empty() {
decode(&record)?;
record.clear();
}
record.extend_from_slice(&line);
anyhow::ensure!(
record.len() <= 16 * 1024 * 1024,
"archive record exceeds 16 MiB"
);
}
decode(&record)
}
fn load_unlocked(root: &Path, host: &str, user: Option<&str>, store: &str) -> Result<Vec<Entry>> {
let mut out = vec![];
scan_unlocked(root, host, user, store, |e| {
out.push(e);
Ok(())
})?;
Ok(out)
}
pub fn load_store(root: &Path, host: &str, user: Option<&str>, store: &str) -> Result<Vec<Entry>> {
let _life = LIFECYCLE
.read()
.map_err(|_| anyhow!("archive lifecycle lock poisoned"))?;
let lock = archive_lock(root, host, user, store)?;
let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?;
load_unlocked(root, host, user, store)
}
fn decode_entry(v: &V) -> Result<Entry> {
let when = match v.get("when") {
Some(V::Number(x)) => *x,
Some(V::Integer(x)) => *x as f64,
_ => parse_time(&string(
v.get("attr")
.and_then(|a| a.get("stamp"))
.ok_or_else(|| anyhow!("archive timestamp missing"))?,
)?)?,
};
let xml = to_xml(v, true)?;
roxmltree::Document::parse(&xml)?;
Ok(Entry {
id: string(v.get("key").ok_or_else(|| anyhow!("archive id missing"))?)?,
when,
peer: v.get("with").map(string).transpose()?.unwrap_or_default(),
xml,
})
}
fn encode_entry(e: &Entry) -> Result<String> {
let doc = roxmltree::Document::parse(&e.xml)?;
let mut value = from_xml(doc.root_element())?;
value.set("key", s(&e.id));
value.set("when", V::Number(e.when));
value.set("with", s(&e.peer));
let mut attr = value.get("attr").cloned().unwrap_or_else(table);
attr.set("stamp", s(&timestamp(e.when)?));
value.set("attr", attr);
let serialized = storage::serialize(&value)?;
let expr = serialized
.strip_prefix("return ")
.ok_or_else(|| anyhow!("bad serializer"))?
.trim_end()
.trim_end_matches(';');
Ok(format!("item({expr});\n"))
}
/// Replace a generic Prosody archive atomically, invalidating its binary cache.
pub fn save_store(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
entries: &[Entry],
) -> Result<()> {
let _life = LIFECYCLE
.read()
.map_err(|_| anyhow!("archive lifecycle lock poisoned"))?;
let lock = archive_lock(root, host, user, store)?;
let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?;
save_unlocked(root, host, user, store, entries)
}
fn save_unlocked(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
entries: &[Entry],
) -> Result<()> {
let mut data = String::new();
for e in entries {
data.push_str(&encode_entry(e)?);
}
let p = storage::path(root, host, user, store).with_extension("list");
recover_append(&p)?;
match std::fs::remove_file(p.with_extension("lidx")) {
Ok(()) => (),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => return Err(e.into()),
}
storage::atomic_write(&p, data.as_bytes())
}
pub fn append_store(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
entry: Entry,
) -> Result<String> {
let _life = LIFECYCLE
.read()
.map_err(|_| anyhow!("archive lifecycle lock poisoned"))?;
let lock = archive_lock(root, host, user, store)?;
let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?;
let mut exists = false;
scan_unlocked(root, host, user, store, |e| {
exists |= e.id == entry.id;
Ok(())
})?;
let id = entry.id.clone();
if exists {
let mut entries = load_unlocked(root, host, user, store)?;
entries.retain(|v| v.id != entry.id);
entries.push(entry);
save_unlocked(root, host, user, store, &entries)?;
} else {
append_unlocked(root, host, user, store, &entry)?;
}
Ok(id)
}
/// Append an entry whose identifier is newly generated. O(record size), no archive scan.
/// Call append_store instead if replacing an existing identifier is required.
pub fn append_new(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
entry: Entry,
) -> Result<String> {
let _life = LIFECYCLE
.read()
.map_err(|_| anyhow!("archive lifecycle lock poisoned"))?;
let lock = archive_lock(root, host, user, store)?;
let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?;
append_unlocked(root, host, user, store, &entry)?;
Ok(entry.id)
}
fn append_unlocked(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
entry: &Entry,
) -> Result<()> {
let data = encode_entry(entry)?;
let path = storage::path(root, host, user, store).with_extension("list");
let parent = path.parent().unwrap();
std::fs::create_dir_all(parent)?;
recover_append(&path)?;
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(&path)?;
let length = file.metadata()?.len();
let journal = path.with_extension("rappend");
storage::atomic_write(&journal, &length.to_le_bytes())?;
match std::fs::remove_file(path.with_extension("lidx")) {
Ok(()) => (),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => return Err(e.into()),
}
let result = (|| -> Result<()> {
file.write_all(data.as_bytes())?;
file.sync_all()?;
std::fs::remove_file(&journal)?;
std::fs::File::open(parent)?.sync_all()?;
Ok(())
})();
if result.is_err() {
recover_append(&path)?;
}
result
}
/// Owner is localpart, peer is bare JID. Caller enforces archive preferences.
pub fn archive_message(
root: &Path,
domain: &str,
owner: &str,
peer: &str,
xml: &str,
time: f64,
) -> Result<String> {
append_new(
root,
domain,
Some(owner),
"archive",
Entry {
id: uuid::Uuid::new_v4().to_string(),
when: time,
peer: peer.into(),
xml: xml.into(),
},
)
}
/// Delete all entries older than cutoff, preserving archive format and invalidating index.
pub fn expire_before(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
cutoff: f64,
) -> Result<usize> {
let _life = LIFECYCLE
.read()
.map_err(|_| anyhow!("archive lifecycle lock poisoned"))?;
let lock = archive_lock(root, host, user, store)?;
let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?;
retain_unlocked(root, host, user, store, |e| e.when >= cutoff)
}
/// Remove acknowledged records atomically relative to concurrent appends.
pub fn delete_ids(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
ids: &[String],
) -> Result<()> {
let _life = LIFECYCLE
.read()
.map_err(|_| anyhow!("archive lifecycle lock poisoned"))?;
let lock = archive_lock(root, host, user, store)?;
let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?;
let wanted: std::collections::HashSet<&str> = ids.iter().map(String::as_str).collect();
retain_unlocked(root, host, user, store, |e| !wanted.contains(e.id.as_str()))?;
Ok(())
}
fn retain_unlocked<F: FnMut(&Entry) -> bool>(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
mut retain: F,
) -> Result<usize> {
let path = storage::path(root, host, user, store).with_extension("list");
if !path.exists() {
return Ok(0);
}
let parent = path.parent().unwrap();
let temp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
let result = (|| -> Result<usize> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(&temp)?;
let mut removed = 0;
scan_unlocked(root, host, user, store, |e| {
if retain(&e) {
file.write_all(encode_entry(&e)?.as_bytes())?;
} else {
removed += 1;
}
Ok(())
})?;
if removed == 0 {
return Ok(0);
}
file.sync_all()?;
match std::fs::remove_file(path.with_extension("lidx")) {
Ok(()) => (),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => return Err(e.into()),
}
std::fs::rename(&temp, &path)?;
std::fs::File::open(parent)?.sync_all()?;
Ok(removed)
})();
let _ = std::fs::remove_file(&temp);
result
}
+250
View File
@@ -0,0 +1,250 @@
use crate::{lua::bounded_vm, storage::encode_name};
use anyhow::{Context, Result, ensure};
use hmac::{Hmac, Mac};
use sha1::Sha1;
use sha2::{Digest, Sha256};
use std::{
fs,
io::{Read, Write},
path::{Path, PathBuf},
};
use subtle::ConstantTimeEq;
/// Prepare a SASL authentication identity before looking up its XMPP account.
pub fn prepared_auth_username(name: &str) -> Result<String> {
let sasl =
stringprep::saslprep(name).map_err(|_| anyhow::anyhow!("invalid authentication name"))?;
let prepared =
stringprep::nodeprep(&sasl).map_err(|_| anyhow::anyhow!("invalid authentication name"))?;
ensure!(
!prepared.is_empty() && prepared.len() <= 1023,
"invalid authentication name"
);
Ok(prepared.into_owned())
}
pub fn user_name(name: &str) -> Result<()> {
let prepared =
stringprep::nodeprep(name).map_err(|_| anyhow::anyhow!("invalid account name"))?;
ensure!(
!name.is_empty() && name.len() <= 1023 && prepared == name,
"account name must be a normalized XMPP localpart"
);
Ok(())
}
fn account_path(root: &Path, domain: &str, user: &str) -> PathBuf {
root.join(encode_name(domain))
.join("accounts")
.join(format!("{}.dat", encode_name(user)))
}
fn keys(password: &str, salt: &[u8], iterations: u32, hash: &str) -> Result<(Vec<u8>, Vec<u8>)> {
let password =
stringprep::saslprep(password).map_err(|_| anyhow::anyhow!("invalid password encoding"))?;
macro_rules! derive {
($sha:ty,$n:expr) => {{
let mut salted = [0u8; $n];
pbkdf2::pbkdf2_hmac::<$sha>(password.as_bytes(), salt, iterations, &mut salted);
let mut client = Hmac::<$sha>::new_from_slice(&salted)?;
client.update(b"Client Key");
let stored = <$sha>::digest(client.finalize().into_bytes()).to_vec();
let mut server = Hmac::<$sha>::new_from_slice(&salted)?;
server.update(b"Server Key");
(stored, server.finalize().into_bytes().to_vec())
}};
}
Ok(match hash {
"SHA-1" => derive!(Sha1, 20),
"SHA-256" => derive!(Sha256, 32),
_ => anyhow::bail!("unsupported password_hash"),
})
}
/// Prosody `internal_hashed` stores binary salt and hex-encoded SCRAM keys.
#[derive(Clone, Debug)]
pub struct ScramCredentials {
pub salt: Vec<u8>,
pub iterations: u32,
pub stored_key: Vec<u8>,
pub server_key: Vec<u8>,
}
pub fn load_scram(
root: &Path,
domain: &str,
user: &str,
hash: &str,
) -> Result<Option<ScramCredentials>> {
if user_name(user).is_err() {
return Ok(None);
}
let file = match fs::File::open(account_path(root, domain, user)) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let mut data = Vec::new();
file.take(65537).read_to_end(&mut data)?;
ensure!(data.len() <= 65536, "account record too large");
ensure!(
!data.starts_with(b"\x1bLua"),
"binary Lua account record rejected"
);
let lua = bounded_vm().map_err(|e| anyhow::anyhow!("{e}"))?;
let env = lua.create_table().map_err(|e| anyhow::anyhow!("{e}"))?;
let table: mlua::Table = lua
.load(data)
.set_environment(env)
.eval()
.map_err(|_| anyhow::anyhow!("invalid account record"))?;
let read = || -> mlua::Result<Option<ScramCredentials>> {
if table.get::<Option<bool>>("disabled")?.unwrap_or(false) {
return Ok(None);
}
// Legacy plaintext accounts are derivable without changing the original record.
if let Some(password) = table.get::<Option<String>>("password")? {
let salt = uuid::Uuid::new_v4().to_string().into_bytes();
let iterations = 10000;
let (stored_key, server_key) =
keys(&password, &salt, iterations, hash).map_err(mlua::Error::external)?;
return Ok(Some(ScramCredentials {
salt,
iterations,
stored_key,
server_key,
}));
}
let salt: mlua::LuaString = table.get("salt")?;
let iterations: u32 = table.get("iteration_count")?;
let stored: String = table.get("stored_key")?;
let server: String = table.get("server_key")?;
Ok(Some(ScramCredentials {
salt: salt.as_bytes().to_vec(),
iterations,
stored_key: hex::decode(stored).map_err(mlua::Error::external)?,
server_key: hex::decode(server).map_err(mlua::Error::external)?,
}))
};
let result = read().map_err(|_| anyhow::anyhow!("unsupported/incomplete account record"))?;
if let Some(ref c) = result {
let len = match hash {
"SHA-1" => 20,
"SHA-256" => 32,
_ => anyhow::bail!("unsupported password_hash"),
};
ensure!(
c.stored_key.len() == len
&& c.server_key.len() == len
&& !c.salt.is_empty()
&& (1..=1_000_000).contains(&c.iterations),
"invalid SCRAM credentials"
);
}
Ok(result)
}
pub fn verify(root: &Path, domain: &str, user: &str, password: &str, hash: &str) -> Result<bool> {
ensure!(password.len() <= 4096, "password too large");
let Ok(user) = prepared_auth_username(user) else {
return Ok(false);
};
let Some(c) = load_scram(root, domain, &user, hash)? else {
return Ok(false);
};
let (stored, server) = keys(password, &c.salt, c.iterations, hash)?;
Ok(bool::from(
stored.ct_eq(&c.stored_key) & server.ct_eq(&c.server_key),
))
}
/// Changes a password while preserving all unrelated Prosody account fields.
pub fn set_password(
root: &Path,
domain: &str,
user: &str,
password: &str,
hash: &str,
) -> Result<()> {
use crate::storage::{self, LuaValue};
user_name(user)?;
ensure!(
!password.is_empty() && password.len() <= 4096,
"invalid password length"
);
// `update` serializes mutations; verify existence inside its closure to avoid creating accounts.
storage::update(root, domain, Some(user), "accounts", |account| {
ensure!(
account_path(root, domain, user).is_file(),
"account does not exist"
);
let iterations = account
.get("iteration_count")
.and_then(LuaValue::as_i64)
.unwrap_or(10000)
.max(10000);
ensure!(iterations <= 1_000_000, "invalid iteration count");
let salt = uuid::Uuid::new_v4().to_string();
let (stored, server) = keys(password, salt.as_bytes(), iterations as u32, hash)?;
account.set("stored_key", hex::encode(stored).into());
account.set("server_key", hex::encode(server).into());
account.set("salt", salt.into());
account.set("iteration_count", LuaValue::Integer(iterations));
account.set("password", LuaValue::Nil);
account.set(
"updated",
LuaValue::Integer(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs() as i64,
),
);
Ok(())
})?;
Ok(())
}
pub fn create(root: &Path, domain: &str, user: &str, password: &str, hash: &str) -> Result<()> {
user_name(user)?;
ensure!(
!password.is_empty() && password.len() <= 4096,
"invalid password length"
);
let salt = uuid::Uuid::new_v4().to_string();
let it = 10000;
let (stored, server) = keys(password, salt.as_bytes(), it, hash)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
let content = format!(
"return {{\n stored_key = \"{}\";\n server_key = \"{}\";\n salt = \"{}\";\n iteration_count = {};\n created = {};\n updated = {};\n}}\n",
hex::encode(stored),
hex::encode(server),
salt,
it,
now,
now
);
crate::storage::with_exclusive_access(|| {
let path = account_path(root, domain, user);
let parent = path.parent().context("no parent")?;
fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
}
// Publish a fully fsynced file without replacing an existing account.
let temp = parent.join(format!(".new-{}", uuid::Uuid::new_v4()));
let result = (|| -> Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut f = options.open(&temp)?;
f.write_all(content.as_bytes())?;
f.sync_all()?;
fs::hard_link(&temp, &path).context("account already exists or cannot be published")?;
fs::File::open(parent)?.sync_all()?;
Ok(())
})();
let _ = fs::remove_file(temp);
result
})
}
+372
View File
@@ -0,0 +1,372 @@
use crate::lua::{bounded_vm, json_value};
use anyhow::{Context, Result, bail, ensure};
use mlua::{Lua, Table, Value};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value as Json};
use std::{
cell::RefCell,
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
rc::Rc,
};
const MAX_FILE: u64 = 1024 * 1024;
pub type Options = Map<String, Json>;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub global: Options,
#[serde(default)]
pub hosts: BTreeMap<String, Host>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Host {
#[serde(default)]
pub component: Option<String>,
#[serde(default)]
pub options: Options,
}
#[derive(Debug, Serialize)]
pub struct Diagnostic {
pub level: String,
pub scope: String,
pub message: String,
}
pub fn load(path: &Path) -> Result<Config> {
let text = read_small(path)?;
let config: Config = match path.extension().and_then(|s| s.to_str()) {
Some("lua") => load_lua(path)?,
Some("json") => serde_json::from_str(&text).context("invalid JSON configuration")?,
Some("yaml" | "yml") => {
serde_yaml_ng::from_str(&text).context("invalid YAML configuration")?
}
Some("toml") => toml::from_str(&text).context("invalid TOML configuration")?,
_ => bail!("expected .lua, .json, .yaml, .yml or .toml"),
};
config.validate()?;
Ok(config)
}
fn read_small(path: &Path) -> Result<String> {
ensure!(
fs::metadata(path)?.len() <= MAX_FILE,
"configuration file exceeds 1 MiB"
);
fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))
}
fn host_name(name: &str) -> Result<()> {
ensure!(
!name.is_empty()
&& name.is_ascii()
&& name != "*"
&& !name.contains(['/', '\\', '@', ':'])
&& !name.chars().any(char::is_whitespace),
"invalid/unsupported host name (use ASCII/punycode)"
);
Ok(())
}
impl Config {
pub fn option(&self, host: &str, key: &str) -> Option<&Json> {
self.hosts
.get(host)
.and_then(|h| h.options.get(key))
.or_else(|| self.global.get(key))
}
/// Explicit configured modules only; implicit dependencies are not claimed as resolved.
pub fn modules(&self, name: &str) -> Result<BTreeSet<String>> {
let host = self.hosts.get(name).context("unknown host")?;
let mut enabled = BTreeSet::new();
let inherit = host
.options
.get("load_global_modules")
.and_then(Json::as_bool)
.unwrap_or(host.component.is_none());
if inherit {
enabled.extend(string_list(self.global.get("modules_enabled"))?);
}
enabled.extend(string_list(host.options.get("modules_enabled"))?);
let mut disabled = string_list(self.global.get("modules_disabled"))?;
disabled.extend(string_list(host.options.get("modules_disabled"))?);
for item in disabled {
enabled.remove(&item);
}
if let Some(component) = &host.component {
enabled.insert(component.clone());
}
Ok(enabled)
}
pub fn validate(&self) -> Result<()> {
ensure!(
!self.hosts.is_empty(),
"no virtual hosts or components configured"
);
for (name, host) in &self.hosts {
host_name(name)?;
ensure!(
name == &name.to_ascii_lowercase(),
"host names must be lowercase"
);
if let Some(c) = &host.component {
ensure!(!c.is_empty(), "empty component module");
}
}
for (scope, options) in std::iter::once(("*", &self.global))
.chain(self.hosts.iter().map(|(n, h)| (n.as_str(), &h.options)))
{
for key in ["modules_enabled", "modules_disabled"] {
string_list(options.get(key)).with_context(|| format!("{scope}: {key}"))?;
}
for key in ["c2s_ports", "s2s_ports", "http_ports", "https_ports"] {
if let Some(value) = options.get(key) {
if value.as_object().is_some_and(Map::is_empty) {
continue;
}
let list = value
.as_array()
.with_context(|| format!("{scope}: {key} must be a list"))?;
let mut seen = BTreeSet::new();
for port in list {
let p = port
.as_u64()
.filter(|n| (1..=65535).contains(n))
.context("port must be an integer in 1..65535")?;
ensure!(seen.insert(p), "duplicate port in {scope}: {key}");
}
}
}
for key in [
"allow_registration",
"load_global_modules",
"c2s_require_encryption",
"s2s_require_encryption",
"advertise_bosh",
"advertise_websocket",
] {
if let Some(v) = options.get(key) {
ensure!(v.is_boolean(), "{scope}: {key} must be boolean");
}
}
}
crate::http_config::validate(self)?;
crate::http_proxy::ProxyConfig::from_options(&self.global)?;
Ok(())
}
pub fn diagnostics(&self) -> Vec<Diagnostic> {
let mut out = vec![];
for (name, h) in &self.hosts {
let modules = self.modules(name).unwrap_or_default();
if self.option(name, "allow_registration") == Some(&Json::Bool(true))
&& modules.contains("register")
{
out.push(Diagnostic {
level: "info".into(),
scope: name.clone(),
message: "Public registration is enabled by configuration".into(),
});
}
if modules.contains("seclabels")
&& self
.option(name, "security_labels")
.and_then(|v| v.get("policies"))
.is_some()
{
out.push(Diagnostic { level:"warning".into(), scope:name.clone(), message:"security_labels policies/classifications supplies catalog metadata only; no access-control enforcement. Invalid schemas are rejected.".into() });
}
if h.component.is_none() && modules.contains("pep_simple") {
out.push(Diagnostic {
level: "info".into(),
scope: name.clone(),
message: "mod_pep_simple is empty in the supplied Prosody revision".into(),
});
}
}
out
}
}
fn string_list(v: Option<&Json>) -> Result<Vec<String>> {
match v {
None => Ok(vec![]),
Some(Json::Object(m)) if m.is_empty() => Ok(vec![]),
Some(Json::Array(a)) => a
.iter()
.map(|v| {
v.as_str()
.map(str::to_owned)
.context("expected string list entry")
})
.collect(),
_ => bail!("expected a list of strings"),
}
}
#[derive(Clone)]
struct State {
scopes: Table,
stack: Rc<RefCell<Vec<PathBuf>>>,
files: Rc<RefCell<usize>>,
}
fn load_lua(path: &Path) -> Result<Config> {
let lua = bounded_vm().map_err(|e| anyhow::anyhow!("{e}"))?;
let scopes = lua.create_table().map_err(|e| anyhow::anyhow!("{e}"))?;
scopes
.set("*", lua.create_table().map_err(|e| anyhow::anyhow!("{e}"))?)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let state = State {
scopes,
stack: Default::default(),
files: Default::default(),
};
evaluate(&lua, path, state.clone()).map_err(|e| anyhow::anyhow!("Lua configuration: {e}"))?;
let mut config = Config::default();
for pair in state.scopes.pairs::<String, Table>() {
let (name, table) = pair.map_err(|e| anyhow::anyhow!("{e}"))?;
let Json::Object(mut options) =
json_value(Value::Table(table), 0).map_err(|e| anyhow::anyhow!("{e}"))?
else {
bail!("scope is not a table");
};
if name == "*" {
config.global = options;
} else {
let component = options
.remove("component_module")
.and_then(|v| v.as_str().map(str::to_owned));
options.remove("defined");
config.hosts.insert(name, Host { component, options });
}
}
Ok(config)
}
fn evaluate(lua: &Lua, path: &Path, state: State) -> mlua::Result<()> {
let path = path.canonicalize().map_err(mlua::Error::external)?;
if state.stack.borrow().contains(&path) || state.stack.borrow().len() >= 16 {
return Err(mlua::Error::runtime("Include cycle/depth limit"));
}
*state.files.borrow_mut() += 1;
if *state.files.borrow() > 128 {
return Err(mlua::Error::runtime("Include file count limit"));
}
let source = read_small(&path).map_err(|e| mlua::Error::runtime(e.to_string()))?;
state.stack.borrow_mut().push(path.clone());
let env = lua.create_table()?;
let current = Rc::new(RefCell::new("*".to_owned()));
for (alias, component) in [
("VirtualHost", false),
("Host", false),
("host", false),
("Component", true),
("component", true),
] {
let scopes = state.scopes.clone();
let current = current.clone();
env.set(
alias,
lua.create_function(move |lua, name: String| {
host_name(&name).map_err(|e| mlua::Error::runtime(e.to_string()))?;
let name = name.to_ascii_lowercase();
let existing: Option<Table> = scopes.get(name.as_str())?;
let t = existing.unwrap_or(lua.create_table()?);
let previous_component: Option<String> = t.get("component_module")?;
let defined: Option<bool> = t.get("defined")?;
if (component && defined == Some(true) && previous_component.is_none())
|| (!component && previous_component.is_some())
{
return Err(mlua::Error::runtime("VirtualHost/Component name clash"));
}
t.set("defined", true)?;
if component {
t.set("component_module", "component")?;
t.set("load_global_modules", false)?;
}
scopes.set(name.as_str(), t.clone())?;
*current.borrow_mut() = name;
let reset = current.clone();
lua.create_function(move |lua, value: Value| {
let apply_table = {
let t = t.clone();
let reset = reset.clone();
move |_: &Lua, opts: Table| {
for p in opts.pairs::<String, Value>() {
let (k, v) = p?;
t.set(k, v)?;
}
*reset.borrow_mut() = "*".into();
Ok(())
}
};
match value {
Value::String(s) if component => {
t.set("component_module", s)?;
Ok(Value::Function(lua.create_function(apply_table)?))
}
Value::Table(opts) => {
apply_table(lua, opts)?;
Ok(Value::Nil)
}
_ => Err(mlua::Error::runtime(
"invalid VirtualHost/Component declaration",
)),
}
})
})?,
)?;
}
let parent = path.parent().unwrap().to_path_buf();
let include_state = state.clone();
let include = lua.create_function(move |lua, pattern: String| {
let target = parent.join(pattern);
let pattern = target
.to_str()
.ok_or_else(|| mlua::Error::runtime("non-UTF8 Include path"))?;
let mut matches = glob::glob(pattern)
.map_err(mlua::Error::external)?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(mlua::Error::external)?;
matches.sort();
if matches.is_empty() {
return Err(mlua::Error::runtime("Include matched no files"));
}
for file in matches {
evaluate(lua, &file, include_state.clone())?;
}
Ok(())
})?;
env.set("Include", include.clone())?;
env.set("include", include)?;
let mt = lua.create_table()?;
let read_scopes = state.scopes.clone();
let read_current = current.clone();
mt.set(
"__index",
lua.create_function(move |_, (_, key): (Table, String)| {
let scope: Table = read_scopes.get(read_current.borrow().as_str())?;
let value: Value = scope.get(key.as_str())?;
if matches!(value, Value::Nil) {
return Err(mlua::Error::runtime(format!(
"undefined or unsupported config symbol: {key}"
)));
}
Ok(value)
})?,
)?;
let scopes = state.scopes.clone();
mt.set(
"__newindex",
lua.create_function(move |_, (_, key, value): (Table, String, Value)| {
let scope: Table = scopes.get(current.borrow().as_str())?;
scope.set(key, value)
})?,
)?;
env.set_metatable(Some(mt))?;
let result = lua
.load(&source)
.set_name(path.to_string_lossy().as_ref())
.set_environment(env)
.exec();
state.stack.borrow_mut().pop();
result
}
+172
View File
@@ -0,0 +1,172 @@
//! Write buffering matching the supplied Prosody mod_csi_simple importance policy.
use crate::{protocol, wire::escape};
use std::time::{Duration, Instant};
#[derive(Clone, Debug, Default)]
pub struct Buffer {
inactive: bool,
flushing_until: Option<Instant>,
queue: Vec<String>,
bytes: usize,
pub limit: usize,
pub resume_delay: Duration,
pub important_payloads: Vec<String>,
}
impl Buffer {
pub fn configure(&mut self, limit: usize, delay: Duration, payloads: Vec<String>) {
self.limit = limit.max(1);
self.resume_delay = delay;
self.important_payloads = payloads;
}
pub fn set_inactive(&mut self, inactive: bool) -> Vec<String> {
self.inactive = inactive;
self.flushing_until = None;
if inactive { Vec::new() } else { self.drain() }
}
pub fn reset(&mut self) {
self.inactive = false;
self.flushing_until = None;
self.queue.clear();
self.bytes = 0;
}
fn drain(&mut self) -> Vec<String> {
self.bytes = 0;
std::mem::take(&mut self.queue)
}
/// Client input flushes held output without declaring the client active.
pub fn activity(&mut self, now: Instant) -> Vec<String> {
if self.inactive && !self.queue.is_empty() {
self.flushing_until = Some(now + self.resume_delay);
self.drain()
} else {
Vec::new()
}
}
pub fn output(&mut self, xml: String, owner: &str, now: Instant) -> Vec<String> {
if !self.inactive || self.flushing_until.is_some_and(|until| now < until) {
return vec![xml];
}
self.flushing_until = None;
if self.queue.len() >= self.limit.max(1)
|| self.bytes.saturating_add(xml.len()) > 4 * 1024 * 1024
|| important(&xml, &self.important_payloads)
{
self.flushing_until = Some(now + self.resume_delay);
let mut output = self.drain();
output.push(xml);
output
} else {
let xml = delayed(&xml, owner);
self.bytes += xml.len();
self.queue.push(xml);
Vec::new()
}
}
}
fn has(n: roxmltree::Node<'_, '_>, ns: &str, name: &str) -> bool {
n.children().any(|c| c.has_tag_name((ns, name)))
}
fn matches_path(n: roxmltree::Node<'_, '_>, path: &str) -> bool {
let (namespace, rest) = if let Some(p) = path.strip_prefix('{') {
let Some((ns, rest)) = p.split_once('}') else {
return false;
};
(ns, rest)
} else {
(n.tag_name().namespace().unwrap_or("jabber:client"), path)
};
let (name, next) = rest
.split_once('/')
.map_or((rest, None), |(name, rest)| (name, Some(rest)));
n.children().filter(|c| c.is_element()).any(|c| {
(name == "*" || c.tag_name().name() == name)
&& c.tag_name().namespace().unwrap_or("jabber:client") == namespace
&& next.is_none_or(|p| matches_path(c, p))
})
}
pub fn important(xml: &str, payloads: &[String]) -> bool {
let wrapped = format!("<root xmlns='jabber:client'>{xml}</root>");
let Ok(d) = roxmltree::Document::parse(&wrapped) else {
return true;
};
let Some(mut n) = d.root_element().first_element_child() else {
return true;
};
if n.tag_name().namespace() != Some("jabber:client") {
return true;
}
match n.tag_name().name() {
"presence" => !matches!(n.attribute("type"), None | Some("unavailable" | "error")),
"iq" => true,
"message" => {
if n.attribute("type") == Some("headline") {
return false;
}
if n.attribute("type") == Some("error") || has(n, "urn:xmpp:carbons:2", "sent") {
return true;
}
if let Some(inner) = n
.children()
.find(|c| c.has_tag_name(("urn:xmpp:carbons:2", "received")))
.and_then(|c| {
c.children()
.find(|c| c.has_tag_name(("urn:xmpp:forward:0", "forwarded")))
})
.and_then(|c| {
c.children()
.find(|c| c.has_tag_name(("jabber:client", "message")))
})
{
n = inner;
}
has(n, "jabber:client", "body")
|| has(n, "jabber:client", "subject")
|| has(n, "urn:xmpp:eme:0", "encryption")
|| has(n, "jabber:x:conference", "x")
|| n.children().any(|c| {
c.has_tag_name(("http://jabber.org/protocol/muc#user", "x"))
&& c.children().any(|c| c.tag_name().name() == "invite")
})
|| n.children().any(|c| {
c.is_element()
&& matches!(
c.tag_name().namespace(),
Some("urn:xmpp:jingle-message:0" | "urn:xmpp:jingle-message:1")
)
})
|| payloads.iter().any(|p| matches_path(n, p))
}
_ => false,
}
}
fn delayed(xml: &str, owner: &str) -> String {
let wrapped = format!("<root xmlns='jabber:client'>{xml}</root>");
let Ok(d) = roxmltree::Document::parse(&wrapped) else {
return xml.into();
};
let Some(n) = d.root_element().first_element_child() else {
return xml.into();
};
if n.tag_name().namespace() != Some("jabber:client") || n.tag_name().name() == "iq" {
return xml.into();
}
let mut output = protocol::xml(n);
if let Some(at) = output.rfind("</") {
output.insert_str(
at,
&format!(
"<delay xmlns='urn:xmpp:delay' from='{}' stamp='{}'/>",
escape(owner),
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
),
);
} else if output.ends_with("/>") {
output.truncate(output.len() - 2);
output.push_str(&format!(
"><delay xmlns='urn:xmpp:delay' from='{}' stamp='{}'/></{}>",
escape(owner),
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
n.tag_name().name()
));
}
output
}
+24
View File
@@ -0,0 +1,24 @@
//! Process-wide ownership of the data directory, independent of listening ports.
use anyhow::{Context, Result};
use std::{fs::File, path::Path};
/// Keep this guard alive while any runtime task can mutate the directory.
/// On Linux, flock locks the directory inode, so aliases and symlinks cannot
/// bypass it and no sentinel file needs to be deleted after a crash.
/// Prosody itself does not participate in this lock.
pub struct DataDirectoryGuard {
_directory: File,
}
impl DataDirectoryGuard {
pub fn acquire(root: &Path) -> Result<Self> {
std::fs::create_dir_all(root).context("cannot create data directory")?;
let directory = File::open(root).context("cannot open data directory")?;
directory.try_lock().context(
"cannot exclusively lock data directory; another rust-xmpp process may be using it",
)?;
Ok(Self {
_directory: directory,
})
}
}
+321
View File
@@ -0,0 +1,321 @@
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(())
}
+797
View File
@@ -0,0 +1,797 @@
//! Authenticated XMPP server-to-server transport: STARTTLS + SASL EXTERNAL.
//! No dialback or plaintext fallback: a peer must prove ownership of its source domain
//! through a trusted certificate and the TLS CertificateVerify signature.
use crate::wire::{Framer, escape};
use anyhow::{Context, Result, ensure};
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use hickory_resolver::{TokioAsyncResolver, error::ResolveErrorKind};
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
time::Duration,
};
use tokio::{
io::{AsyncRead, AsyncWrite, AsyncWriteExt},
net::TcpStream,
sync::{Mutex, mpsc, oneshot},
time::{Instant, timeout},
};
use tokio_rustls::{
TlsAcceptor, TlsConnector,
rustls::{
self, ClientConfig, DigitallySignedStruct, DistinguishedName, RootCertStore, ServerConfig,
SignatureScheme,
client::{
WebPkiServerVerifier,
danger::{HandshakeSignatureValid, ServerCertVerifier},
},
pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime},
server::danger::{ClientCertVerified, ClientCertVerifier},
},
};
const STREAM: &str = "http://etherx.jabber.org/streams";
const SERVER: &str = "jabber:server";
const TLS: &str = "urn:ietf:params:xml:ns:xmpp-tls";
const SASL: &str = "urn:ietf:params:xml:ns:xmpp-sasl";
const HANDSHAKE: Duration = Duration::from_secs(30);
#[derive(Clone, Debug)]
pub struct FederatedStanza {
pub from_domain: String,
pub xml: String,
}
#[derive(Clone)]
pub struct Federation {
inner: Arc<Inner>,
local_domains: BTreeSet<String>,
/// Explicit operator routing overrides; TLS identity is always checked against the JID domain.
targets: BTreeMap<String, (String, u16)>,
outbound_idle: Duration,
}
type PoolKey = (String, String, Option<(String, u16)>);
type PeerConnection = Arc<Mutex<Option<Outbound>>>;
const MAX_OUTBOUND: usize = 128;
struct Delivery {
stanza: String,
complete: oneshot::Sender<Result<()>>,
}
struct Outbound {
sender: mpsc::Sender<Delivery>,
last_used: Instant,
}
struct Authenticated {
tls: tokio_rustls::client::TlsStream<TcpStream>,
framer: Framer,
}
struct Inner {
certs: Vec<CertificateDer<'static>>,
key: PrivateKeyDer<'static>,
roots: Arc<RootCertStore>,
client: Arc<ClientConfig>,
resolver: TokioAsyncResolver,
outbound: Mutex<BTreeMap<PoolKey, PeerConnection>>,
}
impl Federation {
pub fn new(
domain: String,
certs: Vec<CertificateDer<'static>>,
key: PrivateKeyDer<'static>,
roots: RootCertStore,
) -> Result<Self> {
let domain = canonical_domain(&domain)?;
ensure!(!roots.is_empty(), "empty federation trust store");
let roots = Arc::new(roots);
let client = ClientConfig::builder()
.with_root_certificates(roots.clone())
.with_client_auth_cert(certs.clone(), key.clone_key())?;
Ok(Self {
inner: Arc::new(Inner {
certs,
key,
roots,
client: Arc::new(client),
resolver: TokioAsyncResolver::tokio_from_system_conf()?,
outbound: Mutex::new(BTreeMap::new()),
}),
local_domains: BTreeSet::from([domain]),
targets: BTreeMap::new(),
outbound_idle: Duration::from_secs(60),
})
}
pub fn add_local_domain(&mut self, domain: String) -> Result<()> {
self.local_domains.insert(canonical_domain(&domain)?);
Ok(())
}
pub fn set_target(&mut self, domain: String, host: String, port: u16) -> Result<()> {
let domain = canonical_domain(&domain)?;
ensure!(port != 0 && !host.is_empty(), "invalid federation target");
self.targets.insert(domain, (host, port));
Ok(())
}
pub fn native_roots() -> Result<RootCertStore> {
let native = rustls_native_certs::load_native_certs();
let mut roots = RootCertStore::empty();
for cert in native.certs {
roots.add(cert)?;
}
ensure!(!roots.is_empty(), "no native trust anchors available");
Ok(roots)
}
async fn targets(&self, domain: &str) -> Result<Vec<(String, u16)>> {
if let Some(target) = self.targets.get(domain) {
return Ok(vec![target.clone()]);
}
let lookup = self
.inner
.resolver
.srv_lookup(format!("_xmpp-server._tcp.{domain}."))
.await;
let mut records = match lookup {
Ok(v) => v.iter().cloned().collect::<Vec<_>>(),
Err(e) if matches!(e.kind(), ResolveErrorKind::NoRecordsFound { .. }) => {
return Ok(vec![(domain.into(), 5269)]);
}
Err(e) => return Err(e.into()),
};
ensure!(
!records.iter().any(|r| r.target().is_root()),
"domain explicitly disables XMPP federation"
);
records.sort_by_key(|r| r.priority());
let mut targets = Vec::new();
while !records.is_empty() {
let priority = records[0].priority();
let end = records
.iter()
.take_while(|r| r.priority() == priority)
.count();
let sum: u64 = records[..end].iter().map(|r| r.weight() as u64).sum();
let random =
u64::from_be_bytes(uuid::Uuid::new_v4().as_bytes()[..8].try_into().unwrap());
let chosen = if sum == 0 {
random as usize % end
} else {
let draw = random % sum;
let mut acc = 0;
records[..end]
.iter()
.position(|r| {
acc += r.weight() as u64;
draw < acc
})
.unwrap_or(0)
};
let r = records.remove(chosen);
targets.push((r.target().to_utf8().trim_end_matches('.').into(), r.port()));
}
if targets.is_empty() {
targets.push((domain.into(), 5269));
}
Ok(targets)
}
pub async fn route(&self, from_domain: &str, to_jid: &str, xml: &str) -> Result<()> {
let from_domain = canonical_domain(from_domain)?;
ensure!(
self.local_domains.contains(&from_domain),
"unowned federation sender domain"
);
let to_domain = jid_domain(to_jid)?;
validate_stanza(xml, &from_domain, &to_domain)?;
timeout(HANDSHAKE, self.route_inner(&from_domain, &to_domain, xml))
.await
.context("federation route timeout")?
}
/// Configure before routing. Idle sockets close independently of subsequent traffic.
pub fn with_outbound_idle_timeout(mut self, duration: Duration) -> Result<Self> {
ensure!(
duration >= Duration::from_millis(10) && duration <= Duration::from_secs(3600),
"invalid federation idle timeout"
);
self.outbound_idle = duration;
Ok(self)
}
async fn route_inner(&self, from: &str, to: &str, xml: &str) -> Result<()> {
let key = (
from.to_owned(),
to.to_owned(),
self.targets.get(to).cloned(),
);
let peer = {
let mut pool = self.inner.outbound.lock().await;
// Closed actors cannot retain TLS sockets. Never await peer locks under the map lock.
pool.retain(|_, peer| {
if Arc::strong_count(peer) > 1 {
return true;
}
peer.try_lock().map_or(true, |connection| {
connection.as_ref().is_some_and(|c| !c.sender.is_closed())
})
});
if let Some(peer) = pool.get(&key) {
peer.clone()
} else {
ensure!(
pool.len() < MAX_OUTBOUND,
"federation connection pool is full"
);
let peer = Arc::new(Mutex::new(None));
pool.insert(key, peer.clone());
peer
}
};
let mut connection = peer.lock().await;
for attempt in 0..2 {
if connection.as_ref().is_some_and(|c| {
c.sender.is_closed() || c.last_used.elapsed() >= self.outbound_idle
}) {
*connection = None;
}
if connection.is_none() {
let targets = self.targets(to).await?;
let mut last = None;
let mut authenticated = None;
for (host, port) in targets {
match self.connect(&host, port, from, to).await {
Ok(c) => {
authenticated = Some(c);
break;
}
Err(e) => last = Some(e),
}
}
let authenticated = authenticated.ok_or_else(|| {
last.unwrap_or_else(|| anyhow::anyhow!("no federation endpoints"))
})?;
let (sender, receiver) = mpsc::channel(1);
tokio::spawn(outbound_actor(authenticated, receiver, self.outbound_idle));
*connection = Some(Outbound {
sender,
last_used: Instant::now(),
});
}
let (complete, result) = oneshot::channel();
let delivery = Delivery {
stanza: server_stanza(xml)?,
complete,
};
let ready = connection.as_mut().unwrap();
if ready.sender.send(delivery).await.is_err() {
*connection = None;
// send() returns the owned delivery only when the actor never took it.
// Reconnecting is safe in this case; writes with ambiguous outcomes
// below are deliberately never replayed.
if attempt == 0 {
continue;
}
anyhow::bail!("federation connection closed before delivery");
}
// A canceled route or failed write has an ambiguous delivery outcome. Never replay it.
let outcome = result.await.unwrap_or_else(|_| {
Err(anyhow::anyhow!(
"federation connection closed during delivery"
))
});
if outcome.is_err() {
*connection = None;
} else {
ready.last_used = Instant::now();
}
return outcome;
}
unreachable!("bounded delivery attempts return or fail")
}
async fn connect(&self, host: &str, port: u16, from: &str, to: &str) -> Result<Authenticated> {
let mut tcp = timeout(Duration::from_secs(8), TcpStream::connect((host, port))).await??;
tcp.set_nodelay(true)?;
let mut f = Framer::default();
send(&mut tcp, &header(from, to)).await?;
read_header(&mut tcp, &mut f, Some(to), Some(from)).await?;
let features = frame(&mut tcp, &mut f).await?;
ensure!(
feature(&features, TLS, "starttls")?,
"peer does not offer STARTTLS"
);
send(&mut tcp, &format!("<starttls xmlns='{TLS}'/>")).await?;
let proceed = frame(&mut tcp, &mut f).await?;
ensure!(element(&proceed, TLS, "proceed")?, "STARTTLS rejected");
ensure!(f.buffer.is_empty(), "unexpected cleartext after proceed");
let name = ServerName::try_from(to.to_owned())?;
let mut tls = TlsConnector::from(self.inner.client.clone())
.connect(name, tcp)
.await?;
let mut f = Framer::default();
send(&mut tls, &header(from, to)).await?;
read_header(&mut tls, &mut f, Some(to), Some(from)).await?;
let features = frame(&mut tls, &mut f).await?;
ensure!(
external_feature(&features)?,
"peer does not offer SASL EXTERNAL"
);
send(
&mut tls,
&format!(
"<auth xmlns='{SASL}' mechanism='EXTERNAL'>{}</auth>",
B64.encode(from)
),
)
.await?;
let success = frame(&mut tls, &mut f).await?;
ensure!(
element(&success, SASL, "success")?,
"peer rejected EXTERNAL"
);
send(&mut tls, &header(from, to)).await?;
read_header(&mut tls, &mut f, Some(to), Some(from)).await?;
let features = frame(&mut tls, &mut f).await?;
ensure!(
element(&features, STREAM, "features")?,
"missing postauth features"
);
Ok(Authenticated { tls, framer: f })
}
pub async fn accept(
&self,
tcp: TcpStream,
inbound: mpsc::Sender<FederatedStanza>,
) -> Result<()> {
// One deadline covers the entire unauthenticated protocol, including SASL
// and stream restarts; per-frame deadlines otherwise let peers pin sockets.
let (mut tls, mut f, from) = timeout(HANDSHAKE, self.authenticate(tcp))
.await
.context("federation authentication timeout")??;
loop {
let text = frame(&mut tls, &mut f).await?;
if f.is_stream_end(&text) {
send(&mut tls, "</stream:stream>").await?;
return Ok(());
}
let destination = {
let w = wrap(&text);
let d = roxmltree::Document::parse(&w)?;
let n = d
.root_element()
.first_element_child()
.context("missing stanza")?;
ensure!(
n.tag_name().namespace() == Some(SERVER),
"invalid S2S stanza namespace"
);
jid_domain(n.attribute("to").context("missing destination")?)?
};
ensure!(
self.local_domains.contains(&destination),
"unserved destination domain"
);
validate_stanza(&text, &from, &destination)?;
timeout(
Duration::from_secs(15),
inbound.send(FederatedStanza {
from_domain: from.clone(),
xml: text,
}),
)
.await??;
}
}
async fn authenticate(
&self,
mut tcp: TcpStream,
) -> Result<(tokio_rustls::server::TlsStream<TcpStream>, Framer, String)> {
tcp.set_nodelay(true)?;
let mut f = Framer::default();
let (from, to) = read_header(&mut tcp, &mut f, None, None).await?;
ensure!(
self.local_domains.contains(&to),
"unknown destination domain"
);
send(&mut tcp,&format!("{}<stream:features><starttls xmlns='{TLS}'><required/></starttls></stream:features>",header(&to,&from))).await?;
let start = frame(&mut tcp, &mut f).await?;
ensure!(element(&start, TLS, "starttls")?, "TLS required");
ensure!(f.buffer.is_empty(), "TLS pipelining rejected");
let verifier = Arc::new(DomainVerifier {
inner: WebPkiServerVerifier::builder(self.inner.roots.clone()).build()?,
name: ServerName::try_from(from.clone())?,
});
let config = ServerConfig::builder()
.with_client_cert_verifier(verifier)
.with_single_cert(self.inner.certs.clone(), self.inner.key.clone_key())?;
send(&mut tcp, &format!("<proceed xmlns='{TLS}'/>")).await?;
let mut tls = timeout(HANDSHAKE, TlsAcceptor::from(Arc::new(config)).accept(tcp)).await??;
let mut f = Framer::default();
read_header(&mut tls, &mut f, Some(&from), Some(&to)).await?;
send(&mut tls,&format!("{}<stream:features><mechanisms xmlns='{SASL}'><mechanism>EXTERNAL</mechanism></mechanisms></stream:features>",header(&to,&from))).await?;
let auth = frame(&mut tls, &mut f).await?;
{
let w = wrap(&auth);
let d = roxmltree::Document::parse(&w)?;
let n = d
.root_element()
.first_element_child()
.context("missing auth")?;
ensure!(
n.tag_name().namespace() == Some(SASL)
&& n.tag_name().name() == "auth"
&& n.attribute("mechanism") == Some("EXTERNAL"),
"EXTERNAL required"
);
let payload = n.text().unwrap_or("");
let authz = if payload == "=" {
Vec::new()
} else {
B64.decode(payload)?
};
ensure!(
authz.is_empty()
|| std::str::from_utf8(&authz)
.ok()
.and_then(|value| canonical_domain(value).ok())
.is_some_and(|value| value == from),
"EXTERNAL identity mismatch"
);
}
send(&mut tls, &format!("<success xmlns='{SASL}'/>")).await?;
read_header(&mut tls, &mut f, Some(&from), Some(&to)).await?;
send(
&mut tls,
&format!("{}<stream:features/>", header(&to, &from)),
)
.await?;
Ok((tls, f, from))
}
}
/// One task owns each TLS stream so peer closure is observed even when no writes occur.
async fn outbound_actor(
mut connection: Authenticated,
mut queue: mpsc::Receiver<Delivery>,
idle: Duration,
) {
let mut deadline = Instant::now() + idle;
loop {
tokio::select! {
biased;
// Outbound S2S is unidirectional; bidi was never negotiated. Any peer stanza,
// stream error, closing tag or EOF ends this connection without replaying data.
_ = connection.framer.next(&mut connection.tls) => break,
_ = tokio::time::sleep_until(deadline) => break,
delivery = queue.recv() => {
let Some(delivery)=delivery else{break};
if delivery.complete.is_closed(){continue}
let outcome=send(&mut connection.tls,&delivery.stanza).await;
let failed=outcome.is_err();let _=delivery.complete.send(outcome);
if failed{break}deadline=Instant::now()+idle;
}
}
}
queue.close();
while let Ok(delivery) = queue.try_recv() {
let _ = delivery.complete.send(Err(anyhow::anyhow!(
"federation connection retired before write"
)));
}
let _ = timeout(
Duration::from_secs(1),
send(&mut connection.tls, "</stream:stream>"),
)
.await;
let _ = timeout(Duration::from_secs(1), connection.tls.shutdown()).await;
}
#[derive(Debug)]
struct DomainVerifier {
inner: Arc<WebPkiServerVerifier>,
name: ServerName<'static>,
}
impl ClientCertVerifier for DomainVerifier {
fn root_hint_subjects(&self) -> &[DistinguishedName] {
&[]
}
fn verify_client_cert(
&self,
end: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
now: UnixTime,
) -> std::result::Result<ClientCertVerified, rustls::Error> {
self.inner
.verify_server_cert(end, intermediates, &self.name, &[], now)?;
Ok(ClientCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
self.inner.verify_tls12_signature(message, cert, dss)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
self.inner.verify_tls13_signature(message, cert, dss)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.inner.supported_verify_schemes()
}
}
fn canonical_domain(name: &str) -> Result<String> {
// Domain identities use canonical DNS label syntax. IPv4 literals remain
// supported; rustls requires an IP SAN for them rather than a DNS SAN.
ensure!(
!name.is_empty()
&& name.len() <= 253
&& name.is_ascii()
&& name.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label.as_bytes()[0].is_ascii_alphanumeric()
&& label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
&& label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
}),
"invalid federation domain"
);
ServerName::try_from(name.to_owned())?;
Ok(name.to_ascii_lowercase())
}
fn jid_domain(jid: &str) -> Result<String> {
let bare = jid.split('/').next().context("missing JID")?;
let domain = match bare.split_once('@') {
Some((local, domain)) => {
ensure!(!local.is_empty() && !domain.contains('@'), "invalid JID");
domain
}
None => bare,
};
canonical_domain(domain)
}
fn header(from: &str, to: &str) -> String {
format!(
"<stream:stream xmlns='{SERVER}' xmlns:stream='{STREAM}' from='{}' to='{}' id='{}' version='1.0'>",
escape(from),
escape(to),
uuid::Uuid::new_v4()
)
}
fn wrap(xml: &str) -> String {
format!("<root xmlns='{SERVER}' xmlns:stream='{STREAM}'>{xml}</root>")
}
async fn send<S: AsyncWrite + Unpin>(s: &mut S, xml: &str) -> Result<()> {
timeout(Duration::from_secs(15), s.write_all(xml.as_bytes())).await??;
Ok(())
}
async fn frame<S: AsyncRead + Unpin>(s: &mut S, f: &mut Framer) -> Result<String> {
let text = timeout(Duration::from_secs(120), f.next(s)).await??;
if text.starts_with("<?xml")
|| f.is_stream_end(&text)
|| crate::wire::StreamHeader::parse(&text).is_ok()
{
return Ok(text);
}
let w = f.wrap(&text);
let doc = roxmltree::Document::parse(&w)?;
let node = doc
.root_element()
.first_element_child()
.context("missing S2S frame")?;
Ok(crate::protocol::xml(node))
}
async fn read_header<S: AsyncRead + Unpin>(
s: &mut S,
f: &mut Framer,
expected_from: Option<&str>,
expected_to: Option<&str>,
) -> Result<(String, String)> {
let mut text = frame(s, f).await?;
if text.starts_with("<?xml ") {
text = frame(s, f).await?;
}
let header = crate::wire::StreamHeader::parse(&text)?;
ensure!(
header.default_namespace() == Some(SERVER) && header.version.as_deref() == Some("1.0"),
"invalid S2S header"
);
let from = header.from.as_deref().context("missing from domain")?;
let to = header.to.as_deref().context("missing to domain")?;
let from = canonical_domain(from)?;
let to = canonical_domain(to)?;
ensure!(
expected_from.is_none_or(|v| v.eq_ignore_ascii_case(&from))
&& expected_to.is_none_or(|v| v.eq_ignore_ascii_case(&to)),
"stream domain changed"
);
Ok((from, to))
}
fn element(xml: &str, ns: &str, name: &str) -> Result<bool> {
let w = wrap(xml);
let d = roxmltree::Document::parse(&w)?;
Ok(d.root_element()
.first_element_child()
.is_some_and(|n| n.tag_name().namespace() == Some(ns) && n.tag_name().name() == name))
}
fn feature(xml: &str, ns: &str, name: &str) -> Result<bool> {
let w = wrap(xml);
let d = roxmltree::Document::parse(&w)?;
let features = d
.root_element()
.first_element_child()
.context("missing features")?;
ensure!(
features.has_tag_name((STREAM, "features")),
"missing stream features"
);
Ok(features.children().any(|n| n.has_tag_name((ns, name))))
}
fn external_feature(xml: &str) -> Result<bool> {
let w = wrap(xml);
let d = roxmltree::Document::parse(&w)?;
let features = d
.root_element()
.first_element_child()
.context("missing features")?;
ensure!(
features.has_tag_name((STREAM, "features")),
"missing SASL features"
);
Ok(features
.children()
.filter(|n| n.has_tag_name((SASL, "mechanisms")))
.flat_map(|n| n.children())
.any(|n| n.has_tag_name((SASL, "mechanism")) && n.text() == Some("EXTERNAL")))
}
fn validate_stanza(xml: &str, from_domain: &str, to_domain: &str) -> Result<()> {
let w = wrap(xml);
let d = roxmltree::Document::parse(&w)?;
let root = d.root_element();
ensure!(
root.children().filter(|n| n.is_element()).count() == 1,
"multiple stanzas"
);
let n = root.first_element_child().context("missing stanza")?;
ensure!(
matches!(n.tag_name().name(), "iq" | "message" | "presence")
&& matches!(
n.tag_name().namespace(),
Some(SERVER) | Some("jabber:client")
),
"invalid federation stanza"
);
ensure!(
jid_domain(n.attribute("from").context("missing stanza from")?)? == from_domain,
"forged source domain"
);
ensure!(
jid_domain(n.attribute("to").context("missing stanza to")?)? == to_domain,
"wrong destination domain"
);
Ok(())
}
fn server_stanza(xml: &str) -> Result<String> {
let w = wrap(xml);
let d = roxmltree::Document::parse(&w)?;
let n = d
.root_element()
.first_element_child()
.context("missing stanza")?;
Ok(crate::protocol::xml_namespace(
n,
Some(("jabber:client", SERVER)),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn federation_domains_are_dns_names_and_case_insensitive() {
assert_eq!(canonical_domain("Alpha.TEST").unwrap(), "alpha.test");
for invalid in [
"",
"::1",
"[::1]",
"alpha.test.",
".test",
"a..test",
"-alpha.test",
"alpha-.test",
"_xmpp.test",
"alpha@beta.test",
"alpha/test",
] {
assert!(canonical_domain(invalid).is_err(), "{invalid}");
}
assert!(canonical_domain(&format!("{}.test", "a".repeat(64))).is_err());
assert_eq!(jid_domain("alice@ALPHA.TEST/r").unwrap(), "alpha.test");
for invalid in [
"@alpha.test",
"alice@@alpha.test",
"alice@",
"alice@bad_domain.test",
] {
assert!(jid_domain(invalid).is_err(), "{invalid}");
}
validate_stanza(
"<message from='alice@ALPHA.TEST/r' to='bob@BETA.TEST'/>",
"alpha.test",
"beta.test",
)
.unwrap();
}
#[test]
fn starttls_must_be_an_immediate_stream_feature() {
assert!(
feature(
&format!("<stream:features><starttls xmlns='{TLS}'/></stream:features>"),
TLS,
"starttls",
)
.unwrap()
);
assert!(
feature(
&format!("<message><starttls xmlns='{TLS}'/></message>"),
TLS,
"starttls"
)
.is_err()
);
assert!(
!feature(
&format!(
"<stream:features><nested><starttls xmlns='{TLS}'/></nested></stream:features>"
),
TLS,
"starttls",
)
.unwrap()
);
}
#[test]
fn external_must_be_in_the_advertised_mechanisms() {
let mechanisms =
format!("<mechanisms xmlns='{SASL}'><mechanism>EXTERNAL</mechanism></mechanisms>");
assert!(
external_feature(&format!("<stream:features>{mechanisms}</stream:features>")).unwrap()
);
assert!(
!external_feature(&format!(
"<stream:features><extension>{mechanisms}</extension></stream:features>"
))
.unwrap()
);
assert!(!external_feature(&format!("<stream:features><extension><stream:features>{mechanisms}</stream:features></extension></stream:features>")).unwrap());
assert!(external_feature(&format!("<message>{mechanisms}</message>")).is_err());
}
#[tokio::test]
async fn stream_restarts_compare_canonical_domains() {
let text = header("Alpha.TEST", "BETA.TEST");
let mut input = text.as_bytes();
let domains = read_header(
&mut input,
&mut Framer::default(),
Some("alpha.test"),
Some("beta.test"),
)
.await
.unwrap();
assert_eq!(domains, ("alpha.test".into(), "beta.test".into()));
let text = header("gamma.test", "beta.test");
let mut input = text.as_bytes();
assert!(
read_header(
&mut input,
&mut Framer::default(),
Some("alpha.test"),
Some("beta.test")
)
.await
.is_err()
);
}
}
+143
View File
@@ -0,0 +1,143 @@
//! XEP-0156 host-meta discovery, matching the supplied mod_http_altconnect.
use crate::{config::Config, http_config::HttpConfig, http_transport::HttpRoute, wire::escape};
use anyhow::{Context, Result};
use axum::{
Router,
extract::{Request, State},
http::{Method, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use serde::Serialize;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Link {
pub rel: &'static str,
pub href: String,
}
#[derive(Clone)]
struct Discovery {
config: HttpConfig,
xml_path: String,
json_path: String,
xml: String,
json: String,
}
/// Configured values, never request Host or forwarded headers, define public URLs.
pub fn links(config: &Config, domain: &str, secure: bool, port: u16) -> Result<Vec<Link>> {
let http = HttpConfig::for_host(config, domain)?;
let modules = config.modules(domain)?;
let mut links = Vec::new();
for (module, option, rel) in [
("bosh", "advertise_bosh", "urn:xmpp:alt-connections:xbosh"),
(
"websocket",
"advertise_websocket",
"urn:xmpp:alt-connections:websocket",
),
] {
let advertise = config
.option(domain, option)
.map(|v| {
v.as_bool()
.with_context(|| format!("{option} must be boolean"))
})
.transpose()?
.unwrap_or(true);
if modules.contains(module) && advertise {
let url = http.external_url(module, secure, port)?;
let href = if module == "websocket" {
url.replacen("http", "ws", 1)
} else {
url
};
links.push(Link { rel, href });
}
}
Ok(links)
}
pub fn with_altconnect(
router: Router,
config: &Config,
domain: &str,
secure: bool,
port: u16,
) -> Result<Router> {
let http = HttpConfig::for_host(config, domain)?;
let links = links(config, domain, secure, port)?;
let mut xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><XRD xmlns='http://docs.oasis-open.org/ns/xri/xrd-1.0'>".to_owned();
for link in &links {
xml.push_str(&format!(
"<Link rel='{}' href='{}'/>",
link.rel,
escape(&link.href)
));
}
xml.push_str("</XRD>");
let state = Discovery {
xml_path: format!("{}/host-meta", http.altconnect_path.trim_end_matches('/')),
json_path: format!(
"{}/host-meta.json",
http.altconnect_path.trim_end_matches('/')
),
config: http,
xml,
json: serde_json::to_string(&serde_json::json!({"links": links}))?,
};
Ok(router.layer(axum::middleware::from_fn_with_state(state, discovery)))
}
async fn discovery(State(state): State<Discovery>, req: Request, next: Next) -> Response {
let path = req.uri().path();
if path != state.xml_path && path != state.json_path {
return next.run(req).await;
}
let mut response = if let Some(host) = req
.headers()
.get(axum::http::header::HOST)
.and_then(|h| h.to_str().ok())
{
if !state.config.accepts_host(host) {
StatusCode::NOT_FOUND.into_response()
} else if req.method() == Method::OPTIONS {
(
[
("access-control-allow-origin", "*"),
("access-control-allow-methods", "GET, HEAD, OPTIONS"),
("access-control-allow-headers", "Content-Type"),
],
"",
)
.into_response()
} else if req.method() == Method::GET || req.method() == Method::HEAD {
let (mime, body) = if path == state.xml_path {
("application/xrd+xml", state.xml)
} else {
("application/json", state.json)
};
let len = body.len().to_string();
(
[
("content-type", mime),
("access-control-allow-origin", "*"),
("content-length", len.as_str()),
],
if req.method() == Method::HEAD {
String::new()
} else {
body
},
)
.into_response()
} else {
(
StatusCode::METHOD_NOT_ALLOWED,
[("allow", "GET, HEAD, OPTIONS")],
)
.into_response()
}
} else {
StatusCode::BAD_REQUEST.into_response()
};
response.extensions_mut().insert(HttpRoute::Altconnect);
response
}
+395
View File
@@ -0,0 +1,395 @@
//! Prosody-compatible HTTP app mount and advertised URL configuration.
use crate::config::{Config, Options};
use anyhow::{Context, Result, ensure};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Clone, Debug)]
pub struct HttpConfig {
pub host: String,
pub default_host: Option<String>,
pub websocket_path: String,
pub bosh_path: String,
pub file_share_path: String,
pub altconnect_path: String,
pub consider_websocket_secure: bool,
pub consider_bosh_secure: bool,
pub websocket_origins: Vec<String>,
pub bosh_origins: Vec<String>,
known_hosts: BTreeSet<String>,
external: Option<String>,
}
fn string(options: &Options, key: &str) -> Result<Option<String>> {
options
.get(key)
.map(|v| {
v.as_str()
.map(str::to_owned)
.with_context(|| format!("{key} must be a string"))
})
.transpose()
}
fn valid_authority(authority: &axum::http::uri::Authority) -> bool {
let raw = authority.as_str();
if raw.contains('@') {
return false;
}
let suffix = if raw.starts_with('[') {
let Some(end) = raw.find(']') else {
return false;
};
&raw[end + 1..]
} else {
raw.find(':').map(|i| &raw[i..]).unwrap_or("")
};
if suffix.is_empty() {
return true;
}
suffix.strip_prefix(':').is_some_and(|port| {
!port.is_empty() && port.bytes().all(|c| c.is_ascii_digit()) && port.parse::<u16>().is_ok()
})
}
fn host(value: &str) -> Result<String> {
ensure!(
!value.is_empty()
&& value.is_ascii()
&& value
.bytes()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'-')),
"invalid HTTP host: {value}"
);
Ok(value.to_ascii_lowercase())
}
/// Mount paths are literals, never axum route patterns. Reject ambiguous or unsafe forms.
pub fn normalize_path(value: &str) -> Result<String> {
ensure!(
value.is_ascii(),
"HTTP paths must use percent encoding for non-ASCII characters"
);
// Validate pchars directly. No value is passed to a route-pattern parser.
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut offset = 0;
while offset < bytes.len() {
let byte = bytes[offset];
if byte == b'%' {
ensure!(
offset + 2 < bytes.len()
&& bytes[offset + 1].is_ascii_hexdigit()
&& bytes[offset + 2].is_ascii_hexdigit(),
"malformed percent escape in HTTP path"
);
let byte = u8::from_str_radix(&value[offset + 1..offset + 3], 16)?;
ensure!(
byte != b'/' && byte != b'\\' && !byte.is_ascii_control(),
"ambiguous percent escape in HTTP path"
);
decoded.push(byte);
offset += 3;
} else {
ensure!(
byte.is_ascii_alphanumeric() || b"/-._~!&'()*+,;=:@".contains(&byte),
"HTTP path must contain only literal URL path characters"
);
decoded.push(byte);
offset += 1;
}
}
ensure!(
!value.contains("//")
&& !decoded
.split(|b| *b == b'/')
.any(|s| s == b"." || s == b".."),
"invalid HTTP path"
);
let value = if value.starts_with('/') {
value.to_owned()
} else {
format!("/{value}")
};
let value = value.trim_end_matches('/');
Ok(if value.is_empty() {
"/".into()
} else {
value.to_owned()
})
}
fn paths(options: &Options) -> Result<BTreeMap<String, String>> {
let Some(value) = options.get("http_paths") else {
return Ok(BTreeMap::new());
};
let table = value.as_object().context("http_paths must be a table")?;
table
.iter()
.map(|(k, v)| {
Ok((
k.clone(),
v.as_str()
.context("http_paths entries must be strings")?
.to_owned(),
))
})
.collect()
}
impl HttpConfig {
pub fn for_host(config: &Config, domain: &str) -> Result<Self> {
let local = &config
.hosts
.get(domain)
.context("unknown HTTP virtual host")?
.options;
let global_paths = paths(&config.global)?;
let local_paths = paths(local)?;
let mount = |name: &str, default: &str| -> Result<String> {
normalize_path(
&local_paths
.get(name)
.or_else(|| global_paths.get(name))
.map(String::as_str)
.unwrap_or(default)
.replace("$host", domain),
)
};
let effective_host = |name: &str| -> Result<String> {
let h = &config.hosts.get(name).context("unknown HTTP host")?.options;
host(
&string(h, "http_host")?
.or(string(&config.global, "http_host")?)
.unwrap_or_else(|| name.into()),
)
};
let mut known_hosts = BTreeSet::new();
for name in config.hosts.keys() {
known_hosts.insert(effective_host(name)?);
}
let default_host = string(&config.global, "http_default_host")?
.map(|s| host(&s))
.transpose()?;
let origins = |module: &str| -> Result<Vec<String>> {
let value = config
.option(domain, "http_cors_override")
.and_then(|v| v.get(module))
.and_then(|v| v.get("origins"))
.or_else(|| config.option(domain, "access_control_allow_origins"));
match value {
None => Ok(vec![]),
Some(v) if v.as_object().is_some_and(|m| m.is_empty()) => Ok(vec![]),
Some(v) if v.as_str() == Some("*") => Ok(vec!["*".into()]),
Some(v) => v
.as_array()
.context("HTTP origins must be a list")?
.iter()
.map(|v| {
v.as_str()
.map(str::to_owned)
.context("HTTP origins entries must be strings")
})
.collect(),
}
};
let boolean = |key: &str| -> Result<bool> {
config
.option(domain, key)
.map(|v| {
v.as_bool()
.with_context(|| format!("{key} must be boolean"))
})
.unwrap_or(Ok(false))
};
let external =
string(local, "http_external_url")?.or(string(&config.global, "http_external_url")?);
if let Some(url) = &external {
parse_external(url)?;
}
Ok(Self {
host: effective_host(domain)?,
default_host,
websocket_path: mount("websocket", "/xmpp-websocket")?,
bosh_path: mount("bosh", "/http-bind")?,
file_share_path: mount("file_share", "/file_share")?,
altconnect_path: mount("altconnect", "/.well-known")?,
consider_websocket_secure: boolean("consider_websocket_secure")?,
consider_bosh_secure: boolean("consider_bosh_secure")?,
websocket_origins: origins("websocket")?,
bosh_origins: origins("bosh")?,
known_hosts,
external,
})
}
pub fn accepts_host(&self, authority: &str) -> bool {
let Ok(authority) = authority.parse::<axum::http::uri::Authority>() else {
return false;
};
if !valid_authority(&authority) {
return false;
}
let requested = authority.host().to_ascii_lowercase();
requested == self.host
|| (!self.known_hosts.contains(&requested)
&& self.default_host.as_deref() == Some(self.host.as_str()))
}
/// External reverse-proxy prefixes affect discovery only, never local mounts.
pub fn external_url(&self, app: &str, secure: bool, port: u16) -> Result<String> {
let path = match app {
"websocket" => &self.websocket_path,
"bosh" => &self.bosh_path,
"file_share" => &self.file_share_path,
"altconnect" => &self.altconnect_path,
_ => anyhow::bail!("unknown HTTP app"),
};
if let Some(external) = &self.external {
let uri = parse_external(external)?;
let scheme = uri.scheme_str().unwrap();
let authority = uri.authority().unwrap();
let authority =
if authority.port_u16() == Some(if scheme == "https" { 443 } else { 80 }) {
authority.host().to_owned()
} else {
authority.to_string()
};
return Ok(format!(
"{scheme}://{authority}{}{path}",
uri.path().trim_end_matches('/')
));
}
let scheme = if secure { "https" } else { "http" };
let port = if port == if secure { 443 } else { 80 } {
String::new()
} else {
format!(":{port}")
};
Ok(format!("{scheme}://{}{port}{path}", self.host))
}
}
fn parse_external(value: &str) -> Result<axum::http::Uri> {
let uri: axum::http::Uri = value.parse().context("invalid http_external_url")?;
ensure!(
matches!(uri.scheme_str(), Some("http" | "https"))
&& uri.authority().is_some()
&& !uri.authority().unwrap().as_str().contains('@')
&& uri.query().is_none()
&& !value.contains('#'),
"http_external_url must be an absolute HTTP(S) URL without credentials, query or fragment"
);
ensure!(
valid_authority(uri.authority().unwrap()),
"http_external_url port must be in 0..65535"
);
Ok(uri)
}
pub fn validate(config: &Config) -> Result<()> {
for options in std::iter::once(&config.global).chain(config.hosts.values().map(|h| &h.options))
{
for value in paths(options)?.values() {
normalize_path(&value.replace("$host", "example.test"))?;
}
}
for domain in config.hosts.keys() {
let http = HttpConfig::for_host(config, domain)?;
let modules = config.modules(domain)?;
ensure!(
!(modules.contains("websocket")
&& modules.contains("bosh")
&& http.websocket_path == http.bosh_path),
"websocket and bosh HTTP paths conflict for {domain}"
);
if modules.contains("http_altconnect") {
for suffix in ["host-meta", "host-meta.json"] {
let path = format!("{}/{suffix}", http.altconnect_path.trim_end_matches('/'));
ensure!(
!(modules.contains("websocket") && http.websocket_path == path
|| modules.contains("bosh") && http.bosh_path == path),
"http_altconnect path conflicts with XMPP transport for {domain}"
);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn config(value: serde_json::Value) -> Config {
serde_json::from_value(value).unwrap()
}
#[test]
fn mounts_inherit_per_app_and_expand_virtual_host() {
let c = config(
serde_json::json!({"global":{"http_paths":{"websocket":"global/$host/","bosh":"bind/"}},"hosts":{"a.test":{"options":{"http_paths":{"websocket":"custom/$host"}}}}}),
);
let h = HttpConfig::for_host(&c, "a.test").unwrap();
assert_eq!(h.websocket_path, "/custom/a.test");
assert_eq!(h.bosh_path, "/bind");
}
#[test]
fn routes_hosts_and_default_without_overriding_known_hosts() {
let c = config(
serde_json::json!({"global":{"http_default_host":"web.test"},"hosts":{"a.test":{"options":{"http_host":"web.test"}},"b.test":{"options":{}}}}),
);
let h = HttpConfig::for_host(&c, "a.test").unwrap();
assert!(h.accepts_host("WEB.TEST:5280"));
assert!(h.accepts_host("unknown.test"));
assert!(!h.accepts_host("b.test"));
assert!(!h.accepts_host("bad host"));
}
#[test]
fn external_prefix_and_standard_port() {
let c = config(
serde_json::json!({"global":{"http_external_url":"https://proxy.test:443/prefix/"},"hosts":{"a.test":{"options":{}}}}),
);
assert_eq!(
HttpConfig::for_host(&c, "a.test")
.unwrap()
.external_url("bosh", false, 5280)
.unwrap(),
"https://proxy.test/prefix/http-bind"
);
}
#[test]
fn literal_pchars_and_percent_encoding_are_supported() {
for path in [
"/chat/v1:ws",
"/bind%20endpoint",
"/a/*",
"/a@b!&'()+,;=:_~-.%41",
] {
assert_eq!(normalize_path(path).unwrap(), path);
}
}
#[test]
fn invalid_paths_types_urls_and_conflicts_are_errors() {
for path in [
"/a/{capture}",
"/../b",
"/a?x",
"/a#b",
"/a//b",
"/$unknown",
"/a%2fb",
"/a%5cb",
"/a%00b",
"/%2e%2E/b",
"/a%",
"/a%xx",
"/a b",
"/a\\b",
] {
assert!(normalize_path(path).is_err(), "{path}");
}
for global in [
serde_json::json!({"http_paths":false}),
serde_json::json!({"http_paths":{"bosh":false}}),
serde_json::json!({"http_external_url":"https://u:p@a.test"}),
serde_json::json!({"http_external_url":"relative/path"}),
serde_json::json!({"modules_enabled":["websocket","bosh"],"http_paths":{"websocket":"same/","bosh":"/same"}}),
] {
assert!(
validate(&config(
serde_json::json!({"global":global,"hosts":{"a.test":{"options":{}}}})
))
.is_err()
);
}
}
}
+403
View File
@@ -0,0 +1,403 @@
//! Trusted reverse-proxy connection metadata. Headers never authenticate a socket peer.
use crate::config::Options;
use anyhow::{Result, bail, ensure};
use axum::http::HeaderMap;
use std::net::{IpAddr, SocketAddr};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RequestContext {
pub socket_peer: SocketAddr,
pub peer_ip: IpAddr,
pub secure: bool,
}
#[derive(Clone, Debug)]
pub struct ProxyConfig {
trusted: Vec<Network>,
legacy: bool,
}
#[derive(Clone, Debug)]
struct Network {
ip: IpAddr,
prefix: u8,
}
impl Network {
fn parse(value: &str) -> Result<Self> {
let (ip, prefix) = match value.split_once('/') {
Some((ip, prefix)) => (ip.parse::<IpAddr>()?, Some(prefix.parse::<u8>()?)),
None => (value.parse::<IpAddr>()?, None),
};
let bits = if ip.is_ipv4() { 32 } else { 128 };
let prefix = prefix.unwrap_or(bits);
ensure!(prefix <= bits, "invalid trusted_proxies network prefix");
Ok(Self { ip, prefix })
}
fn contains(&self, ip: IpAddr) -> bool {
match (self.ip, ip) {
(IpAddr::V4(network), IpAddr::V4(ip)) => {
let mask = u32::MAX
.checked_shl(32 - u32::from(self.prefix))
.unwrap_or(0);
u32::from(network) & mask == u32::from(ip) & mask
}
(IpAddr::V6(network), IpAddr::V6(ip)) => {
let mask = u128::MAX
.checked_shl(128 - u32::from(self.prefix))
.unwrap_or(0);
u128::from(network) & mask == u128::from(ip) & mask
}
_ => false,
}
}
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
trusted: vec![
Network::parse("127.0.0.1").unwrap(),
Network::parse("::1").unwrap(),
],
legacy: true,
}
}
}
impl ProxyConfig {
pub fn from_options(options: &Options) -> Result<Self> {
let mut config = Self::default();
if let Some(value) = options.get("trusted_proxies") {
// Empty native Lua tables are represented as empty JSON objects.
let values: &[serde_json::Value] = if let Some(values) = value.as_array() {
values
} else if value.as_object().is_some_and(|table| table.is_empty()) {
&[]
} else {
bail!("trusted_proxies must be an array of IP addresses or CIDR networks")
};
config.trusted = values
.iter()
.map(|value| {
let Some(value) = value.as_str() else {
bail!("trusted_proxies entries must be strings")
};
Network::parse(value).map_err(|_| {
anyhow::anyhow!("invalid trusted_proxies IP address or CIDR network")
})
})
.collect::<Result<_>>()?;
}
if let Some(value) = options.get("http_legacy_x_forwarded") {
config.legacy = value
.as_bool()
.ok_or_else(|| anyhow::anyhow!("http_legacy_x_forwarded must be a boolean"))?;
}
Ok(config)
}
fn trusted(&self, ip: IpAddr) -> bool {
self.trusted.iter().any(|network| network.contains(ip))
}
pub fn resolve(
&self,
socket_peer: SocketAddr,
secure: bool,
headers: &HeaderMap,
) -> RequestContext {
let mut context = RequestContext {
socket_peer,
peer_ip: socket_peer.ip(),
secure,
};
if !self.trusted(context.peer_ip) {
return context;
}
if self.legacy {
// Match Prosody: the rightmost non-proxy wins; all-proxy lists keep the socket IP.
if let Some(value) = combined(headers, "x-forwarded-for") {
for ip in value.split(',').filter_map(|v| parse_ip(v.trim())) {
if !self.trusted(ip) {
context.peer_ip = ip;
}
}
}
context.secure |= combined(headers, "x-forwarded-proto").as_deref() == Some("https");
} else if let Some(value) = combined(headers, "forwarded")
&& let Some(elements) = forwarded(&value)
{
let mut first_hop = true;
for (ip, proto) in elements.into_iter().rev() {
if !self.trusted(context.peer_ip) {
break;
}
context.peer_ip = ip;
// The immediate trusted proxy may terminate TLS. Once that
// assertion is accepted, every earlier consumed hop must
// also report HTTPS; plaintext cannot be upgraded again.
let hop_secure = proto.as_deref() == Some("https");
context.secure = if first_hop {
hop_secure
} else {
context.secure && hop_secure
};
first_hop = false;
}
}
context
}
}
fn combined(headers: &HeaderMap, name: &str) -> Option<String> {
let values = headers
.get_all(name)
.iter()
.map(|v| v.to_str())
.collect::<std::result::Result<Vec<_>, _>>()
.ok()?;
(!values.is_empty()).then(|| values.join(","))
}
fn parse_ip(value: &str) -> Option<IpAddr> {
value
.parse()
.ok()
.or_else(|| value.parse::<SocketAddr>().ok().map(|v| v.ip()))
.or_else(|| value.strip_prefix('[')?.strip_suffix(']')?.parse().ok())
}
// Quoted delimiters and escapes are handled before splitting Forwarded elements.
fn split_quoted(value: &str, delimiter: char) -> Option<Vec<&str>> {
let (mut quote, mut escape, mut start) = (false, false, 0);
let mut result = Vec::new();
for (i, c) in value.char_indices() {
if escape {
escape = false;
continue;
}
if quote && c == '\\' {
escape = true;
continue;
}
if c == '"' {
quote = !quote;
} else if c == delimiter && !quote {
result.push(&value[start..i]);
start = i + c.len_utf8();
}
}
if quote || escape {
return None;
}
result.push(&value[start..]);
Some(result)
}
fn unquote(value: &str) -> Option<String> {
if !value.starts_with('"') {
return (!value.contains('"')).then(|| value.to_owned());
}
let value = value.strip_prefix('"')?.strip_suffix('"')?;
let mut chars = value.chars();
let mut result = String::new();
while let Some(c) = chars.next() {
result.push(if c == '\\' { chars.next()? } else { c });
}
Some(result)
}
fn forwarded(value: &str) -> Option<Vec<(IpAddr, Option<String>)>> {
split_quoted(value, ',')?
.into_iter()
.map(|element| {
let (mut ip, mut proto) = (None, None);
for pair in split_quoted(element, ';')? {
let (name, value) = pair.trim().split_once('=')?;
let value = unquote(value.trim())?;
if name.eq_ignore_ascii_case("for") {
if ip.is_some() {
return None;
}
ip = Some(parse_ip(&value)?);
} else if name.eq_ignore_ascii_case("proto") {
if proto.is_some() {
return None;
}
proto = Some(value);
}
}
Some((ip?, proto))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn context(
config: &ProxyConfig,
peer: &str,
secure: bool,
values: &[(&str, &str)],
) -> RequestContext {
let mut headers = HeaderMap::new();
for (name, value) in values {
headers.append(
axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
value.parse().unwrap(),
);
}
config.resolve(peer.parse().unwrap(), secure, &headers)
}
#[test]
fn untrusted_socket_ignores_spoofed_headers() {
let c = context(
&ProxyConfig::default(),
"192.0.2.1:80",
false,
&[
("x-forwarded-for", "198.51.100.1"),
("x-forwarded-proto", "https"),
("forwarded", "for=198.51.100.2;proto=https"),
],
);
assert_eq!(c.peer_ip.to_string(), "192.0.2.1");
assert!(!c.secure);
}
#[test]
fn legacy_chain_uses_rightmost_untrusted_and_tls_is_preserved() {
let config = ProxyConfig::from_options(
&serde_json::from_value(
serde_json::json!({"trusted_proxies":["127.0.0.0/8","2001:db8::/32"]}),
)
.unwrap(),
)
.unwrap();
let c = context(
&config,
"127.0.0.1:80",
false,
&[
(
"x-forwarded-for",
"198.51.100.1, 192.0.2.2, [2001:db8::1]:443",
),
("x-forwarded-proto", "https"),
],
);
assert_eq!(c.peer_ip.to_string(), "192.0.2.2");
assert!(c.secure);
let c = context(
&config,
"127.0.0.1:80",
true,
&[
("x-forwarded-for", "127.0.0.2"),
("x-forwarded-proto", "http"),
],
);
assert_eq!(c.peer_ip.to_string(), "127.0.0.1");
assert!(c.secure);
}
#[test]
fn forwarded_mode_stops_at_first_untrusted_hop() {
let config = ProxyConfig::from_options(
&serde_json::from_value(serde_json::json!({"http_legacy_x_forwarded":false})).unwrap(),
)
.unwrap();
let c = context(
&config,
"127.0.0.1:80",
true,
&[
(
"forwarded",
"for=198.51.100.1;proto=http, for=\"[2001:db8::2]:443\";proto=https",
),
("x-forwarded-proto", "http"),
],
);
assert_eq!(c.peer_ip.to_string(), "2001:db8::2");
assert!(c.secure);
assert!(
context(
&config,
"127.0.0.1:80",
false,
&[("forwarded", "for=192.0.2.1;proto=https")]
)
.secure
);
}
#[test]
fn forwarded_tls_termination_cannot_upgrade_a_plaintext_chain_hop() {
let config = ProxyConfig::from_options(
&serde_json::from_value(serde_json::json!({"http_legacy_x_forwarded":false,"trusted_proxies":["127.0.0.0/8"]})).unwrap(),
)
.unwrap();
for value in [
"for=192.0.2.1;proto=http,for=127.0.0.2;proto=https",
"for=192.0.2.1;proto=https,for=127.0.0.2;proto=http",
] {
let c = context(&config, "127.0.0.1:80", false, &[("forwarded", value)]);
assert_eq!(c.peer_ip.to_string(), "192.0.2.1");
assert!(!c.secure);
}
}
#[test]
fn malformed_forwarded_and_unknown_identifiers_are_ignored() {
let config = ProxyConfig::from_options(
&serde_json::from_value(serde_json::json!({"http_legacy_x_forwarded":false})).unwrap(),
)
.unwrap();
for value in [
"for=unknown",
"for=_hidden",
"for=\"unfinished",
"for=192.0.2.1;for=192.0.2.2",
] {
assert_eq!(
context(&config, "127.0.0.1:80", true, &[("forwarded", value)])
.peer_ip
.to_string(),
"127.0.0.1"
);
}
}
#[test]
fn empty_native_lua_table_disables_proxy_trust() {
for value in [serde_json::json!({}), serde_json::json!([])] {
let config = ProxyConfig::from_options(
&serde_json::from_value(serde_json::json!({"trusted_proxies":value})).unwrap(),
)
.unwrap();
let c = context(
&config,
"127.0.0.1:80",
false,
&[
("x-forwarded-for", "192.0.2.1"),
("x-forwarded-proto", "https"),
],
);
assert_eq!(c.peer_ip.to_string(), "127.0.0.1");
assert!(!c.secure);
}
}
#[test]
fn config_validation_and_network_boundaries() {
for value in [
serde_json::json!({"trusted_proxies":["10.0.0.1/33"]}),
serde_json::json!({"trusted_proxies":"*"}),
serde_json::json!({"http_legacy_x_forwarded":"yes"}),
] {
assert!(ProxyConfig::from_options(&serde_json::from_value(value).unwrap()).is_err());
}
assert!(
Network::parse("0.0.0.0/0")
.unwrap()
.contains("192.0.2.1".parse().unwrap())
);
assert!(
!Network::parse("127.0.0.0/8")
.unwrap()
.contains("128.0.0.1".parse().unwrap())
);
assert!(
!Network::parse("::/0")
.unwrap()
.contains("192.0.2.1".parse().unwrap())
);
}
}
+1037
View File
File diff suppressed because it is too large. Load diff
+942
View File
@@ -0,0 +1,942 @@
//! RFC 7395 WebSocket adapter to the shared XMPP stream engine.
use crate::wire::{Framer, escape};
use axum::{
Router,
extract::{
State, WebSocketUpgrade,
ws::{Message, WebSocket},
},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use futures_util::{SinkExt, StreamExt};
use std::{future::Future, pin::Pin, sync::Arc};
use tokio::io::{AsyncWriteExt, DuplexStream};
pub type ConnectionHandler =
Arc<dyn Fn(DuplexStream) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
/// The effective HTTP connection context is supplied by the HTTP listener.
pub type ContextConnectionHandler = Arc<
dyn Fn(
DuplexStream,
Option<crate::http_proxy::RequestContext>,
) -> Pin<Box<dyn Future<Output = ()> + Send>>
+ Send
+ Sync,
>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HttpRoute {
Websocket,
Bosh,
Altconnect,
}
#[derive(Clone)]
struct ConfiguredState {
handler: ContextConnectionHandler,
bosh: BoshState,
config: crate::http_config::HttpConfig,
websocket: bool,
bosh_enabled: bool,
secure: bool,
}
/// A fallback dispatcher uses literal mount paths, avoiding route-pattern conflicts and panics.
pub fn configured_router(
handler: ContextConnectionHandler,
config: crate::http_config::HttpConfig,
websocket: bool,
bosh: bool,
secure: bool,
) -> anyhow::Result<Router> {
configured_router_with_fallback(handler, config, websocket, bosh, secure, Router::new())
}
/// Compose transport mounts with an existing HTTP app without merging fallbacks.
pub fn configured_router_with_fallback(
handler: ContextConnectionHandler,
config: crate::http_config::HttpConfig,
websocket: bool,
bosh: bool,
secure: bool,
fallback: Router,
) -> anyhow::Result<Router> {
anyhow::ensure!(
!(websocket && bosh && config.websocket_path == config.bosh_path),
"websocket and bosh HTTP paths conflict"
);
let legacy: ConnectionHandler = {
let handler = handler.clone();
Arc::new(move |stream| handler(stream, None))
};
let mut bosh_state = new_bosh_state(legacy);
bosh_state.context_handler = Some(handler.clone());
let state = ConfiguredState {
handler,
bosh: bosh_state,
config,
websocket,
bosh_enabled: bosh,
secure,
};
Ok(fallback
.layer(axum::middleware::from_fn_with_state(
state,
configured_middleware,
))
.layer(axum::extract::DefaultBodyLimit::max(256 * 1024)))
}
async fn configured_middleware(
State(state): State<ConfiguredState>,
request: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
let path = request.uri().path();
let matches =
|mount: &str| path == mount || (mount != "/" && path.strip_suffix('/') == Some(mount));
if (state.websocket && matches(&state.config.websocket_path))
|| (state.bosh_enabled && matches(&state.config.bosh_path))
{
let route = if state.websocket && matches(&state.config.websocket_path) {
HttpRoute::Websocket
} else {
HttpRoute::Bosh
};
let mut response = configured_request(State(state), request).await;
response.extensions_mut().insert(route);
response
} else {
next.run(request).await
}
}
async fn configured_request(
State(state): State<ConfiguredState>,
request: axum::extract::Request,
) -> Response {
use axum::extract::{FromRequest, FromRequestParts};
let host = request
.headers()
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok());
let Some(host) = host else {
return StatusCode::BAD_REQUEST.into_response();
};
if !state.config.accepts_host(host) {
return StatusCode::NOT_FOUND.into_response();
}
let path = request.uri().path();
let matches =
|mount: &str| path == mount || (mount != "/" && path.strip_suffix('/') == Some(mount));
let websocket = state.websocket && matches(&state.config.websocket_path);
let bosh = state.bosh_enabled && matches(&state.config.bosh_path);
if !websocket && !bosh {
return StatusCode::NOT_FOUND.into_response();
}
let context = request
.extensions()
.get::<crate::http_proxy::RequestContext>()
.cloned();
let secure = context.as_ref().map(|c| c.secure).unwrap_or(state.secure);
let considered = if websocket {
state.config.consider_websocket_secure
} else {
state.config.consider_bosh_secure
};
if !secure && !considered {
return StatusCode::FORBIDDEN.into_response();
}
let origins = if websocket {
&state.config.websocket_origins
} else {
&state.config.bosh_origins
};
let origin = request.headers().get(axum::http::header::ORIGIN).cloned();
if let Some(origin) = &origin
&& !origin
.to_str()
.ok()
.is_some_and(|o| origins.iter().any(|a| a == o || a == "*"))
{
return StatusCode::FORBIDDEN.into_response();
}
let method = request.method().clone();
let mut response = if method == axum::http::Method::OPTIONS {
preflight(if websocket {
"GET, OPTIONS"
} else {
"POST, OPTIONS"
})
} else if websocket && method == axum::http::Method::GET {
let (mut parts, _) = request.into_parts();
match WebSocketUpgrade::from_request_parts(&mut parts, &()).await {
Ok(ws) => {
let handler: ConnectionHandler =
Arc::new(move |stream| (state.handler)(stream, context));
upgrade(State(handler), parts.headers, ws).await
}
Err(e) => e.into_response(),
}
} else if bosh && method == axum::http::Method::POST {
match Bytes::from_request(request, &()).await {
Ok(bytes) => bosh_with_context(state.bosh, bytes, context).await,
Err(e) => e.into_response(),
}
} else {
(
StatusCode::METHOD_NOT_ALLOWED,
[(
axum::http::header::ALLOW,
if websocket {
"GET, OPTIONS"
} else {
"POST, OPTIONS"
},
)],
)
.into_response()
};
response
.headers_mut()
.remove(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN);
if let Some(origin) = origin {
response
.headers_mut()
.insert(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
response.headers_mut().append(
axum::http::header::VARY,
axum::http::HeaderValue::from_static("Origin"),
);
}
response
}
pub fn websocket_router(handler: ConnectionHandler) -> Router {
let route = get(upgrade).options(|| async { preflight("GET, OPTIONS") });
Router::new()
.route("/xmpp-websocket", route.clone())
.route("/xmpp-websocket/", route)
.with_state(handler)
}
fn preflight(methods: &'static str) -> Response {
(
[
("access-control-allow-origin", "*"),
("access-control-allow-methods", methods),
("access-control-allow-headers", "Content-Type"),
],
"",
)
.into_response()
}
async fn upgrade(
State(handler): State<ConnectionHandler>,
headers: axum::http::HeaderMap,
ws: WebSocketUpgrade,
) -> Response {
if !headers
.get("sec-websocket-protocol")
.and_then(|v| v.to_str().ok())
.is_some_and(|p| p.split(',').any(|p| p.trim() == "xmpp"))
{
return StatusCode::BAD_REQUEST.into_response();
}
ws.protocols(["xmpp"])
.max_message_size(256 * 1024)
.max_frame_size(256 * 1024)
.on_upgrade(move |socket| bridge(socket, handler))
}
fn from_websocket(input: &str) -> anyhow::Result<String> {
let d = roxmltree::Document::parse(input)?;
let n = d.root_element();
if n.tag_name().namespace() == Some("urn:ietf:params:xml:ns:xmpp-framing") {
match n.tag_name().name() {
"open" => {
let mut out="<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams'".to_string();
for key in ["to", "from", "id", "version"] {
if let Some(v) = n.attribute(key) {
out.push_str(&format!(" {key}='{}'", escape(v)));
}
}
out.push('>');
Ok(out)
}
"close" => Ok("</stream:stream>".into()),
_ => anyhow::bail!("unknown framing element"),
}
} else {
anyhow::ensure!(
matches!(
n.tag_name().namespace(),
Some(
"jabber:client"
| "urn:ietf:params:xml:ns:xmpp-sasl"
| "urn:xmpp:sasl:2"
| "urn:xmpp:sm:3"
| "urn:xmpp:csi:0"
)
),
"unexpected websocket namespace"
);
Ok(input.into())
}
}
fn to_websocket(input: &str) -> anyhow::Result<Option<String>> {
if input.starts_with("<?xml") {
return Ok(None);
}
if input == "</stream:stream>" {
return Ok(Some(
"<close xmlns='urn:ietf:params:xml:ns:xmpp-framing'/>".into(),
));
}
if input.starts_with("<stream:stream ") {
let xml = format!("{input}</stream:stream>");
let d = roxmltree::Document::parse(&xml)?;
let n = d.root_element();
let mut out = "<open xmlns='urn:ietf:params:xml:ns:xmpp-framing'".to_string();
for key in ["to", "from", "id", "version"] {
if let Some(v) = n.attribute(key) {
out.push_str(&format!(" {key}='{}'", escape(v)));
}
}
out.push_str("/>");
return Ok(Some(out));
}
let wrapped = format!(
"<root xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams'>{input}</root>"
);
let d = roxmltree::Document::parse(&wrapped)?;
let n = d
.root_element()
.first_element_child()
.ok_or_else(|| anyhow::anyhow!("no XML"))?;
// Every message is an independent XML document. Serialize the parsed
// subtree so inherited namespaces also survive on children and attributes.
Ok(Some(crate::protocol::xml(n)))
}
async fn bridge(socket: WebSocket, handler: ConnectionHandler) {
let (internal, transport) = tokio::io::duplex(256 * 1024);
let mut worker = tokio::spawn(handler(internal));
let (mut read, mut write) = tokio::io::split(transport);
let (mut tx, mut rx) = socket.split();
let incoming = async {
while let Some(Ok(message)) = rx.next().await {
match message {
Message::Text(text) => {
let text = from_websocket(&text)?;
write.write_all(text.as_bytes()).await?;
}
Message::Close(_) => break,
Message::Binary(_) => anyhow::bail!("binary XMPP frames forbidden"),
_ => {}
}
}
anyhow::Ok(())
};
let outgoing = async {
let mut framer = Framer::default();
loop {
let frame = framer.next(&mut read).await?;
if let Some(text) = to_websocket(&frame)? {
tx.send(Message::Text(text.into())).await?;
}
if frame == "</stream:stream>" {
break;
}
}
anyhow::Ok(())
};
tokio::select! {_=incoming=>{},_=outgoing=>{}}
let _ = tokio::time::timeout(Duration::from_secs(2), tx.close()).await;
// Deliver buffered XMPP close frames before retiring the engine. EOF alone
// remains an ungraceful disconnect, allowing stream management to hibernate.
let _ = write.shutdown().await;
if tokio::time::timeout(Duration::from_secs(5), &mut worker)
.await
.is_err()
{
worker.abort();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn configured_state(secure: bool) -> ConfiguredState {
let config = serde_json::from_value(serde_json::json!({"global":{"http_paths":{"websocket":"/custom/ws","bosh":"/custom/bind"},"http_cors_override":{"websocket":{"origins":["https://allowed.test"]},"bosh":{"origins":["https://allowed.test"]}}},"hosts":{"a.test":{"options":{}}}})).unwrap();
let handler: ContextConnectionHandler =
Arc::new(|_, _| Box::pin(async { panic!("no stream expected") }));
let legacy: ConnectionHandler =
Arc::new(|_| Box::pin(async { panic!("no stream expected") }));
ConfiguredState {
handler,
bosh: new_bosh_state(legacy),
config: crate::http_config::HttpConfig::for_host(&config, "a.test").unwrap(),
websocket: true,
bosh_enabled: true,
secure,
}
}
async fn configured_status(
state: ConfiguredState,
method: &str,
path: &str,
host: &str,
origin: Option<&str>,
context: Option<crate::http_proxy::RequestContext>,
) -> Response {
let mut request = axum::http::Request::builder()
.method(method)
.uri(path)
.header("host", host);
if let Some(origin) = origin {
request = request.header("origin", origin);
}
let mut request = request.body(axum::body::Body::empty()).unwrap();
if let Some(context) = context {
request.extensions_mut().insert(context);
}
configured_request(State(state), request).await
}
#[tokio::test]
async fn configured_mount_host_origin_and_secure_dispatch() {
assert_eq!(
configured_status(
configured_state(true),
"OPTIONS",
"/custom/ws/",
"a.test",
Some("https://allowed.test"),
None
)
.await
.status(),
StatusCode::OK
);
assert_eq!(
configured_status(
configured_state(true),
"OPTIONS",
"/xmpp-websocket",
"a.test",
None,
None
)
.await
.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
configured_status(
configured_state(true),
"OPTIONS",
"/custom/bind",
"unknown.test",
None,
None
)
.await
.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
configured_status(
configured_state(true),
"OPTIONS",
"/custom/bind",
"a.test",
Some("https://bad.test"),
None
)
.await
.status(),
StatusCode::FORBIDDEN
);
assert_eq!(
configured_status(
configured_state(false),
"OPTIONS",
"/custom/bind",
"a.test",
None,
None
)
.await
.status(),
StatusCode::FORBIDDEN
);
let context = crate::http_proxy::RequestContext {
socket_peer: "127.0.0.1:1234".parse().unwrap(),
peer_ip: "198.51.100.1".parse().unwrap(),
secure: true,
};
let response = configured_status(
configured_state(false),
"OPTIONS",
"/custom/bind",
"a.test",
Some("https://allowed.test"),
Some(context),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()[axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN],
"https://allowed.test"
);
assert_eq!(
configured_status(
configured_state(true),
"GET",
"/custom/bind",
"a.test",
None,
None
)
.await
.status(),
StatusCode::METHOD_NOT_ALLOWED
);
}
#[test]
fn independent_framing() {
assert!(from_websocket("<open xmlns='urn:ietf:params:xml:ns:xmpp-framing' to='example.test' version='1.0'/>").unwrap().starts_with("<stream:stream"));
assert!(from_websocket("<message/>").is_err());
assert!(from_websocket("<inactive xmlns='urn:xmpp:csi:0'/>").is_ok());
assert!(from_websocket("<active xmlns='urn:xmpp:csi:0'/>").is_ok());
assert!(to_websocket("<stream:features><mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl'/></stream:features>").unwrap().unwrap().contains("xmlns:stream="));
let xml = to_websocket("<message to='a'><body>x</body></message>")
.unwrap()
.unwrap();
assert_eq!(
roxmltree::Document::parse(&xml)
.unwrap()
.root_element()
.tag_name()
.namespace(),
Some("jabber:client")
);
}
#[test]
fn websocket_preserves_inherited_namespaces_and_spaced_declarations() {
for input in [
"<message xmlns = 'jabber:client'><body>hello</body></message>",
"<stream:features><message><body>hello</body></message></stream:features>",
"<message><body stream:test='value'>hello</body></message>",
] {
let xml = to_websocket(input).unwrap().unwrap();
let doc = roxmltree::Document::parse(&xml).unwrap();
let body = doc.descendants().find(|n| n.has_tag_name("body")).unwrap();
assert_eq!(body.tag_name().namespace(), Some("jabber:client"));
if input.contains("stream:test") {
assert_eq!(
body.attribute(("http://etherx.jabber.org/streams", "test")),
Some("value")
);
}
}
}
#[tokio::test]
async fn bosh_processing_capacity_is_bounded() {
let requests = Arc::new(tokio::sync::Semaphore::new(256));
let _occupied = requests.clone().acquire_many_owned(256).await.unwrap();
let state = BoshState {
handler: Arc::new(|_| Box::pin(async { panic!("must not create a session") })),
sessions: Arc::new(Mutex::new(BTreeMap::new())),
requests,
context_handler: None,
};
let response = bosh(State(state), Bytes::from_static(b"<body/>")).await;
let bytes = axum::body::to_bytes(response.into_body(), 1024)
.await
.unwrap();
assert!(
std::str::from_utf8(&bytes)
.unwrap()
.contains("resource-constraint")
);
}
}
use axum::{body::Bytes, routing::post};
use std::{
collections::{BTreeMap, HashSet},
time::{Duration, Instant},
};
use tokio::sync::{Mutex, Notify, mpsc};
const BOSH: &str = "http://jabber.org/protocol/httpbind";
struct Sequence {
next: u64,
pending: HashSet<u64>,
responses: BTreeMap<u64, String>,
}
struct BoshSession {
domain: String,
wait: u64,
sequence: Mutex<Sequence>,
changed: Notify,
poll_wakeup: Notify,
writer: Mutex<tokio::io::WriteHalf<DuplexStream>>,
output: Mutex<mpsc::Receiver<String>>,
last: std::sync::Mutex<Instant>,
tasks: Vec<tokio::task::JoinHandle<()>>,
engine_done: Arc<Notify>,
}
impl Drop for BoshSession {
fn drop(&mut self) {
for task in &self.tasks {
task.abort();
}
}
}
#[derive(Clone)]
struct BoshState {
handler: ConnectionHandler,
context_handler: Option<ContextConnectionHandler>,
sessions: Arc<Mutex<BTreeMap<String, Arc<BoshSession>>>>,
requests: Arc<tokio::sync::Semaphore>,
}
fn new_bosh_state(handler: ConnectionHandler) -> BoshState {
let state = BoshState {
handler,
context_handler: None,
sessions: Arc::new(Mutex::new(BTreeMap::new())),
requests: Arc::new(tokio::sync::Semaphore::new(256)),
};
let weak = Arc::downgrade(&state.sessions);
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
let Some(map) = weak.upgrade() else { break };
map.lock()
.await
.retain(|_, s| s.last.lock().unwrap().elapsed() < Duration::from_secs(120));
}
});
state
}
pub fn bosh_router(handler: ConnectionHandler) -> Router {
let state = new_bosh_state(handler);
let route = post(bosh).options(|| async { preflight("POST, OPTIONS") });
Router::new()
.route("/http-bind", route.clone())
.route("/http-bind/", route)
.layer(axum::extract::DefaultBodyLimit::max(256 * 1024))
.with_state(state)
}
fn bosh_reply(xml: String) -> Response {
(
[
(axum::http::header::CONTENT_TYPE, "text/xml; charset=utf-8"),
(
axum::http::HeaderName::from_static("access-control-allow-origin"),
"*",
),
],
xml,
)
.into_response()
}
fn terminate(condition: &str) -> Response {
bosh_reply(format!(
"<body xmlns='{BOSH}' type='terminate' condition='{condition}'/>"
))
}
async fn bosh(State(state): State<BoshState>, bytes: Bytes) -> Response {
bosh_with_context(state, bytes, None).await
}
async fn bosh_with_context(
state: BoshState,
bytes: Bytes,
context: Option<crate::http_proxy::RequestContext>,
) -> Response {
// A disconnected HTTP client must not cancel stanza processing or leave a
// pending RID without its cached response. A retry uses that same response.
let Ok(permit) = state.requests.clone().try_acquire_owned() else {
return terminate("resource-constraint");
};
match tokio::spawn(async move {
let _permit = permit;
bosh_request(state, bytes, context).await
})
.await
{
Ok(response) => response,
Err(_) => terminate("remote-connection-failed"),
}
}
async fn bosh_request(
state: BoshState,
bytes: Bytes,
context: Option<crate::http_proxy::RequestContext>,
) -> Response {
let Ok(text) = std::str::from_utf8(&bytes) else {
return terminate("bad-request");
};
let Ok(doc) = roxmltree::Document::parse(text) else {
return terminate("bad-request");
};
let body = doc.root_element();
if body.tag_name().name() != "body" || body.tag_name().namespace() != Some(BOSH) {
return terminate("bad-request");
};
let Some(rid) = body
.attribute("rid")
.and_then(|r| r.parse::<u64>().ok())
.filter(|r| *r < 9_007_199_254_740_991)
else {
return terminate("bad-request");
};
let initial = body.attribute("sid").is_none();
let sid = body
.attribute("sid")
.map(str::to_string)
.unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string());
let session = if initial {
let Some(domain) = body.attribute("to").filter(|v| !v.is_empty()) else {
return terminate("improper-addressing");
};
if body
.attribute("hold")
.and_then(|h| h.parse::<u64>().ok())
.unwrap_or(1)
> 1
{
return terminate("policy-violation");
}
let wait = body
.attribute("wait")
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(30)
.clamp(1, 60);
let (internal, external) = tokio::io::duplex(256 * 1024);
let engine_done = Arc::new(Notify::new());
let done = engine_done.clone();
let handler = if let Some(handler) = &state.context_handler {
handler(internal, context)
} else {
(state.handler)(internal)
};
let engine = tokio::spawn(async move {
handler.await;
done.notify_one();
});
let (mut read, write) = tokio::io::split(external);
let (tx, rx) = mpsc::channel(128);
let pump = tokio::spawn(async move {
let mut framer = Framer::default();
while let Ok(frame) = framer.next(&mut read).await {
if frame.starts_with("<?xml") || frame.starts_with("<stream:stream ") {
continue;
}
if frame == "</stream:stream>" {
break;
}
let Ok(Some(xml)) = to_websocket(&frame) else {
break;
};
if tx.send(xml).await.is_err() {
break;
}
}
});
let session = Arc::new(BoshSession {
domain: domain.into(),
wait,
sequence: Mutex::new(Sequence {
next: rid,
pending: HashSet::new(),
responses: BTreeMap::new(),
}),
changed: Notify::new(),
poll_wakeup: Notify::new(),
writer: Mutex::new(write),
output: Mutex::new(rx),
last: std::sync::Mutex::new(Instant::now()),
tasks: vec![engine, pump],
engine_done,
});
let mut map = state.sessions.lock().await;
if map.len() >= 4096 {
return terminate("resource-constraint");
}
map.insert(sid.clone(), session.clone());
session
} else {
let map = state.sessions.lock().await;
let Some(session) = map.get(&sid) else {
return terminate("item-not-found");
};
session.clone()
};
*session.last.lock().unwrap() = Instant::now();
match tokio::time::timeout(
Duration::from_secs(90),
process_bosh_request(&state, body, rid, initial, &sid, &session),
)
.await
{
Ok(response) => response,
Err(_) => {
state.sessions.lock().await.remove(&sid);
for task in &session.tasks {
task.abort();
}
terminate("remote-connection-failed")
}
}
}
async fn process_bosh_request(
state: &BoshState,
body: roxmltree::Node<'_, '_>,
rid: u64,
initial: bool,
sid: &str,
session: &BoshSession,
) -> Response {
// A cached RID must never replay its stanzas. Two outstanding requests are allowed.
let reorder_deadline = Instant::now() + Duration::from_secs(5);
let mut seq = loop {
let seq = session.sequence.lock().await;
if rid <= seq.next {
break seq;
}
if rid > seq.next.saturating_add(1) || Instant::now() >= reorder_deadline {
return terminate("item-not-found");
}
drop(seq);
tokio::time::sleep(Duration::from_millis(10)).await;
};
if let Some(cached) = seq.responses.get(&rid) {
return bosh_reply(cached.clone());
}
if seq.pending.contains(&rid) {
drop(seq);
let deadline = Instant::now() + Duration::from_secs(session.wait + 5);
loop {
let notified = session.changed.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if let Some(cached) = session.sequence.lock().await.responses.get(&rid).cloned() {
return bosh_reply(cached);
}
if tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), notified)
.await
.is_err()
{
return terminate("remote-connection-failed");
}
}
}
if rid != seq.next || seq.pending.len() >= 2 {
return terminate("item-not-found");
};
seq.pending.insert(rid);
if seq.pending.len() > 1 {
session.poll_wakeup.notify_one();
}
seq.next += 1;
let mut writer = session.writer.lock().await;
drop(seq);
let mut input = String::new();
if initial || body.attribute(("urn:xmpp:xbosh", "restart")) == Some("true") {
input.push_str(&format!("<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' to='{}' version='1.0'>",escape(&session.domain)));
}
for node in body.children().filter(|n| n.is_element()) {
input.push_str(&crate::protocol::xml(node));
}
let closing = body.attribute("type") == Some("terminate");
if closing {
input.push_str("</stream:stream>");
}
if writer.write_all(input.as_bytes()).await.is_err() {
state.sessions.lock().await.remove(sid);
return terminate("remote-connection-failed");
}
drop(writer);
let mut payload = String::new();
if closing {
// Let the engine consume the stream close and run graceful cleanup before
// removing the session: dropping it aborts the engine task. In particular,
// a reconnect must not race with the old resource remaining registered.
let mut rx = session.output.lock().await;
let _ = tokio::time::timeout(Duration::from_secs(session.wait), async {
while let Some(frame) = rx.recv().await {
payload.push_str(&frame);
}
session.engine_done.notified().await;
})
.await;
} else {
let mut rx = session.output.lock().await;
match tokio::select! { v=tokio::time::timeout(Duration::from_secs(session.wait), rx.recv())=>v, _=session.poll_wakeup.notified()=>Ok(Some(String::new())) }
{
Ok(Some(first)) => {
payload.push_str(&first);
while let Ok(next) = rx.try_recv() {
payload.push_str(&next);
}
}
Ok(None) => {
state.sessions.lock().await.remove(sid);
return terminate("remote-stream-error");
}
Err(_) => {}
}
}
let extra = if initial {
format!(
" sid='{sid}' wait='{}' hold='1' requests='2' inactivity='120' polling='1' ver='1.11' from='{}' xmlns:xmpp='urn:xmpp:xbosh' xmpp:version='1.0'",
session.wait,
escape(&session.domain)
)
} else if closing {
" type='terminate'".into()
} else {
String::new()
};
let result = format!("<body xmlns='{BOSH}'{extra}>{payload}</body>");
let mut seq = session.sequence.lock().await;
seq.pending.remove(&rid);
seq.responses.insert(rid, result.clone());
while seq.responses.len() > 4 {
seq.responses.pop_first();
}
drop(seq);
session.changed.notify_waiters();
if closing {
state.sessions.lock().await.remove(sid);
}
bosh_reply(result)
}
/// Apply the exact configured origin allowlist; requests without Origin remain usable by native clients.
pub fn with_origins(router: Router, origins: Vec<String>) -> Router {
let origins = Arc::new(origins);
router.layer(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let origins = origins.clone();
async move {
let origin = req.headers().get(axum::http::header::ORIGIN).cloned();
if let Some(origin) = &origin
&& !origin
.to_str()
.ok()
.is_some_and(|o| origins.iter().any(|a| a == o || a == "*"))
{
return StatusCode::FORBIDDEN.into_response();
}
let mut response = next.run(req).await;
response
.headers_mut()
.remove(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN);
if let Some(origin) = origin {
response
.headers_mut()
.insert(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
response.headers_mut().append(
axum::http::header::VARY,
axum::http::HeaderValue::from_static("Origin"),
);
}
response
}
},
))
}
+49
View File
@@ -0,0 +1,49 @@
pub mod auth;
pub mod config;
pub mod lua;
pub mod server;
pub mod storage;
pub mod wire;
pub mod protocol;
pub mod sessions;
pub mod sasl;
pub mod extras;
pub mod account_modules;
pub mod archive;
pub mod http_service;
pub mod mam;
pub mod muc;
pub mod pubsub;
pub mod http_transport;
pub mod push;
pub mod federation;
pub mod admin;
pub mod csi;
pub mod data_lock;
pub mod preauth;
pub mod tls_binding;
pub mod logging;
pub mod pep_diagnostics;
pub mod http_config;
pub mod http_proxy;
pub mod muc_recovery;
pub mod muc_history;
pub mod http_altconnect;
+385
View File
@@ -0,0 +1,385 @@
//! Small, instance-owned structured logger with Prosody-style sink configuration.
//!
//! Shorthand level keys mean that level and above; explicit sink strings select
//! exactly one level. `*console` and `*stderr` write to
//! stderr; other destinations are append-only files. Array entries also accept
//! `{ "levels": { "min": "debug", "max": "error" }, "to": "file",
//! "filename": "server.log" }`. No process-global logger is installed.
use anyhow::{Result, anyhow, bail};
use chrono::{SecondsFormat, Utc};
use serde_json::Value;
use std::{
fmt,
fs::{File, OpenOptions},
io::{self, Write},
sync::{Arc, Mutex},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Level {
Debug,
Info,
Warn,
Error,
}
impl Level {
pub fn parse(value: &str) -> Result<Self> {
match value {
"debug" => Ok(Self::Debug),
"info" => Ok(Self::Info),
"warn" | "warning" => Ok(Self::Warn),
"error" => Ok(Self::Error),
_ => bail!("unknown log level: {value}"),
}
}
}
impl fmt::Display for Level {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Debug => "debug",
Self::Info => "info",
Self::Warn => "warn",
Self::Error => "error",
})
}
}
struct Sink {
levels: u8,
output: Output,
}
enum Output {
Stderr,
File { file: File, path: String },
}
struct State {
sinks: Vec<Sink>,
json: bool,
reported_failure: bool,
}
/// Clones share sinks and serialize complete records, including file writes.
#[derive(Clone)]
pub struct Logger {
state: Arc<Mutex<State>>,
}
impl Logger {
pub fn from_config(config: Option<&Value>) -> Result<Self> {
Self::from_config_with_format(config, None)
}
/// `format` is the separate `log_format` option: `plain` (default) or `json`.
pub fn from_config_with_format(config: Option<&Value>, format: Option<&str>) -> Result<Self> {
let json = match format.unwrap_or("plain") {
"plain" => false,
"json" => true,
other => bail!("unknown log format: {other}"),
};
let mut specs = Vec::new();
match config {
None | Some(Value::Null) => {
specs.push((level_range(Level::Info, Level::Error), "*stderr".to_owned()))
}
Some(Value::String(s)) => {
if s.starts_with('*') {
specs.push((level_range(Level::Debug, Level::Error), s.clone()));
} else if let Ok(level) = Level::parse(s) {
specs.push((level_range(level, Level::Error), "*stderr".to_owned()));
} else {
specs.push((level_range(Level::Info, Level::Error), s.clone()));
}
}
Some(Value::Object(map)) if !map.contains_key("to") => {
for (level, destination) in map {
let min = Level::parse(level)?;
let path = destination
.as_str()
.ok_or_else(|| anyhow!("log destination for {level} must be a string"))?;
specs.push((level_range(min, Level::Error), path.to_owned()));
}
}
Some(Value::Object(_)) => specs.push(parse_sink(config.unwrap())?),
Some(Value::Array(entries)) => {
for entry in entries {
specs.push(parse_sink(entry)?);
}
}
_ => bail!("log must be a level, destination, table, or array of sinks"),
}
// Validate all destinations before opening any file.
for (_, destination) in &specs {
if destination.is_empty()
|| (destination.starts_with('*')
&& destination != "*console"
&& destination != "*stderr")
{
bail!("unsupported log destination: {destination}");
}
}
// Each destination owns one handle and receives each record once, even
// when several shorthand ranges or explicit level sets overlap.
let mut destinations: Vec<(u8, String)> = Vec::new();
for (levels, destination) in specs {
let destination = if destination == "*console" {
"*stderr".to_owned()
} else {
destination
};
if let Some((mask, _)) = destinations
.iter_mut()
.find(|(_, existing)| *existing == destination)
{
*mask |= levels;
} else {
destinations.push((levels, destination));
}
}
let mut sinks = Vec::new();
for (levels, destination) in destinations {
let output = if destination.starts_with('*') {
Output::Stderr
} else {
let file = open_log_file(&destination)?;
Output::File {
file,
path: destination,
}
};
sinks.push(Sink { levels, output });
}
Ok(Self {
state: Arc::new(Mutex::new(State {
sinks,
json,
reported_failure: false,
})),
})
}
/// Reopen files after external log rotation. All replacements are prepared
/// before any sink is changed; clones immediately share the new handles.
pub fn reopen(&self) -> Result<()> {
let mut state = self
.state
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let mut replacements = Vec::new();
for (index, sink) in state.sinks.iter().enumerate() {
if let Output::File { path, .. } = &sink.output {
replacements.push((index, open_log_file(path)?));
}
}
for (index, replacement) in replacements {
if let Output::File { file, .. } = &mut state.sinks[index].output {
*file = replacement;
}
}
Ok(())
}
/// Emit one record. Values of credential-bearing fields are always redacted.
/// I/O failures are reported once per logger to stderr and never panic.
pub fn event(
&self,
level: Level,
module: &str,
connection: Option<&str>,
event: &str,
fields: &[(&str, &str)],
) {
let mut state = self
.state
.lock()
.unwrap_or_else(|poison| poison.into_inner());
if !state
.sinks
.iter()
.any(|sink| sink.levels & level_bit(level) != 0)
{
return;
}
let record = format_record(state.json, level, module, connection, event, fields);
let mut failure = false;
for sink in &mut state.sinks {
if sink.levels & level_bit(level) == 0 {
continue;
}
let result = match &mut sink.output {
Output::Stderr => io::stderr().lock().write_all(record.as_bytes()),
Output::File { file, .. } => file.write_all(record.as_bytes()),
};
failure |= result.is_err();
}
if failure && !state.reported_failure {
state.reported_failure = true;
let _ = io::stderr()
.lock()
.write_all(b"logging error: failed to write a log record\n");
}
}
}
fn open_log_file(path: &str) -> Result<File> {
let mut options = OpenOptions::new();
options.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options
.open(path)
.map_err(|err| anyhow!("cannot open log file {path}: {err}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
Ok(file)
}
fn level_bit(level: Level) -> u8 {
1 << level as u8
}
fn level_range(min: Level, max: Level) -> u8 {
[Level::Debug, Level::Info, Level::Warn, Level::Error]
.iter()
.filter(|&&level| level >= min && level <= max)
.fold(0, |mask, &level| mask | level_bit(level))
}
fn parse_sink(value: &Value) -> Result<(u8, String)> {
if let Some(destination) = value.as_str() {
if !destination.starts_with('*') {
bail!("array log destinations must name a sink with *");
}
return Ok((
level_range(Level::Debug, Level::Error),
destination.to_owned(),
));
}
let sink = value
.as_object()
.ok_or_else(|| anyhow!("log sink must be an object or sink name"))?;
let levels = match sink.get("levels") {
None => level_range(Level::Debug, Level::Error),
Some(Value::String(level)) => level_bit(Level::parse(level)?),
Some(Value::Array(levels)) => {
let mut mask = 0;
for level in levels {
mask |= level_bit(Level::parse(
level
.as_str()
.ok_or_else(|| anyhow!("log level must be a string"))?,
)?);
}
mask
}
Some(Value::Object(range)) => {
let parse_bound = |name: &str, default| -> Result<Level> {
match range.get(name) {
None => Ok(default),
Some(value) => Level::parse(
value
.as_str()
.ok_or_else(|| anyhow!("log {name} must be a string"))?,
),
}
};
let min = parse_bound("min", Level::Debug)?;
let max = parse_bound("max", Level::Error)?;
if min > max {
bail!("log minimum exceeds maximum");
}
level_range(min, max)
}
_ => bail!("log levels must be a string, level array, or range"),
};
let destination = match sink.get("to").and_then(Value::as_str) {
Some("file") => sink
.get("filename")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("file log sink requires filename"))?
.to_owned(),
Some("console" | "stderr") => "*stderr".to_owned(),
_ => bail!("unsupported log sink type"),
};
Ok((levels, destination))
}
fn sensitive(key: &str) -> bool {
let key: String = key
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect();
[
"password",
"passwd",
"secret",
"token",
"proof",
"credential",
"authxml",
"raw",
"authorization",
"cookie",
"sasl",
"payload",
]
.iter()
.any(|word| key.contains(word))
}
fn quoted(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "\"[invalid]\"".into())
}
fn format_record(
json: bool,
level: Level,
module: &str,
connection: Option<&str>,
event: &str,
fields: &[(&str, &str)],
) -> String {
let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
if json {
let fields: Vec<Value> = fields.iter().map(|(key, value)| serde_json::json!({"key": key, "value": if sensitive(key) { "[REDACTED]" } else { value }})).collect();
return format!(
"{}\n",
serde_json::json!({"timestamp":timestamp,"level":level.to_string(),"module":module,"connection":connection,"event":event,"fields":fields})
);
}
let mut line = format!(
"timestamp={} level={} module={} event={}",
quoted(&timestamp),
level,
quoted(module),
quoted(event)
);
if let Some(connection) = connection {
line.push_str(&format!(" connection={}", quoted(connection)));
}
for (key, value) in fields {
line.push_str(&format!(
" {}={}",
quoted(key),
quoted(if sensitive(key) { "[REDACTED]" } else { value })
));
}
line.push('\n');
line
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn console_aliases_share_one_sink_with_combined_levels() {
let config = serde_json::json!([
"*console",
"*stderr",
{"to":"console","levels":"info"},
{"to":"stderr","levels":"error"}
]);
let logger = Logger::from_config(Some(&config)).unwrap();
let state = logger.state.lock().unwrap();
assert_eq!(state.sinks.len(), 1);
assert!(matches!(state.sinks[0].output, Output::Stderr));
assert_eq!(
state.sinks[0].levels,
level_range(Level::Debug, Level::Error)
);
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Bounded Lua evaluation for configuration and legacy data. Never executes plugins.
use mlua::{HookTriggers, Lua, Value, VmState};
use serde_json::{Map, Number, Value as Json};
use std::{cell::Cell, rc::Rc};
pub fn bounded_vm() -> mlua::Result<Lua> {
let lua = Lua::new();
lua.set_memory_limit(16 * 1024 * 1024)?;
let count = Rc::new(Cell::new(0_u32));
lua.set_hook(
HookTriggers::new().every_nth_instruction(1000),
move |_, _| {
count.set(count.get() + 1);
if count.get() > 2000 {
return Err(mlua::Error::runtime("Lua instruction budget exceeded"));
}
Ok(VmState::Continue)
},
)?;
Ok(lua)
}
/// Reject mixed/holey tables rather than silently dropping numeric keys.
/// Empty tables become objects (module-list readers accept empty objects).
pub fn json_value(value: Value, depth: usize) -> mlua::Result<Json> {
if depth > 64 {
return Err(mlua::Error::runtime("table nesting/cycle limit exceeded"));
}
Ok(match value {
Value::Nil => Json::Null,
Value::Boolean(v) => Json::Bool(v),
Value::Integer(v) => Json::Number(v.into()),
Value::Number(v) => Json::Number(
Number::from_f64(v).ok_or_else(|| mlua::Error::runtime("non-finite number"))?,
),
Value::String(v) => Json::String(v.to_str()?.to_string()),
Value::Table(t) => {
let mut object = Map::new();
let mut sequence = std::collections::BTreeMap::new();
for pair in t.pairs::<Value, Value>() {
let (key, value) = pair?;
let value = json_value(value, depth + 1)?;
match key {
Value::String(s) => {
object.insert(s.to_str()?.to_string(), value);
}
Value::Integer(i) if i > 0 => {
sequence.insert(i, value);
}
_ => return Err(mlua::Error::runtime("unsupported Lua table key")),
}
}
if sequence.is_empty() {
Json::Object(object)
} else {
if !object.is_empty() || sequence.keys().copied().ne(1..=sequence.len() as i64) {
return Err(mlua::Error::runtime(
"mixed or sparse Lua tables are not supported in configuration",
));
}
Json::Array(sequence.into_values().collect())
}
}
_ => {
return Err(mlua::Error::runtime(
"configuration contains executable or unsupported values",
));
}
})
}
+116
View File
@@ -0,0 +1,116 @@
use anyhow::{Context, Result, bail};
use rust_xmpp::config;
use std::{env, path::Path};
#[tokio::main]
async fn main() {
if let Err(e) = run().await {
eprintln!("error: {e:#}");
std::process::exit(1);
}
}
async fn run() -> Result<()> {
let args: Vec<_> = env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("check") => {
let path = args
.get(1)
.map(String::as_str)
.unwrap_or("/etc/prosody/prosody.cfg.lua");
let (c, d) = rust_xmpp::server::settings_config(Path::new(path))?;
let data = c
.option(&d, "data_path")
.and_then(|v| v.as_str())
.context("data_path missing")?;
let socket = rust_xmpp::server::admin_path(&c, &d, Path::new(data));
let reply = rust_xmpp::admin::query(&socket, "server:status()", None).await?;
if reply.error {
bail!("server status query failed")
};
println!("{}", reply.message);
}
Some("shell") if args.len() >= 3 => {
let (c, d) = rust_xmpp::server::settings_config(Path::new(&args[1]))?;
let data = c
.option(&d, "data_path")
.and_then(|v| v.as_str())
.context("data_path")?;
let path = rust_xmpp::server::admin_path(&c, &d, Path::new(data));
let reply = rust_xmpp::admin::query(&path, &args[2], None).await?;
for line in reply.output {
println!("{line}");
}
if reply.error {
bail!("{}", reply.message)
}
println!("{}", reply.message);
}
Some("--version") => println!(
"rust-xmpp {} — experimental XMPP runtime",
env!("CARGO_PKG_VERSION")
),
Some("check-config") if args.len() == 2 => {
let cfg = config::load(Path::new(&args[1]))?;
println!(
"Configuration parsed and basic types validated. Runtime support is checked separately by serve."
);
println!("Virtual hosts/components: {}", cfg.hosts.len());
for name in cfg.hosts.keys() {
println!(
"{name}: {}",
cfg.modules(name)?
.into_iter()
.collect::<Vec<_>>()
.join(", ")
);
}
for d in cfg.diagnostics() {
println!("{} [{}]: {}", d.level, d.scope, d.message);
}
}
Some("convert") if args.len() == 4 => {
let cfg = config::load(Path::new(&args[1]))?;
let text = match args[2].as_str() {
"json" => serde_json::to_string_pretty(&cfg)?,
"yaml" | "yml" => serde_yaml_ng::to_string(&cfg)?,
"toml" => toml::to_string_pretty(&cfg)?,
_ => bail!("output format must be json, yaml or toml"),
};
// Refuse overwrite; owner-only mode because converted configs contain secrets.
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts
.open(&args[3])
.context("cannot create output (existing files are never overwritten)")?;
use std::io::Write;
file.write_all(text.as_bytes())?;
file.sync_all()?;
println!("Written {}", args[3]);
}
Some("serve") if args.len() == 3 && args[2] == "--experimental" => {
rust_xmpp::server::serve(Path::new(&args[1])).await?;
}
Some("serve") => {
bail!("use serve CONFIG --experimental; full Prosody replacement is not implemented")
}
Some("create-user") if args.len() == 4 && args[3] == "--password-stdin" => {
let (data, domain, hash) = rust_xmpp::server::account_config(Path::new(&args[1]))?;
let _data_guard = rust_xmpp::data_lock::DataDirectoryGuard::acquire(&data)?;
let mut password = String::new();
std::io::stdin().read_line(&mut password)?;
let password = password.trim_end_matches(['\r', '\n']);
rust_xmpp::auth::create(&data, &domain, &args[2], password, &hash)?;
println!("Account created: {}@{}", args[2], domain);
}
Some("--help") | None => println!(
"rust-xmpp check-config PATH\nrust-xmpp convert PATH json|yaml|toml OUTPUT\nrust-xmpp --version\n\nExperimental runtime: serve CONFIG --experimental\ncreate-user CONFIG USER --password-stdin\ncheck [CONFIG] (running server healthcheck)\nshell CONFIG COMMAND\n\nSTARTTLS, SCRAM/PLAIN, local messaging, roster, MAM, MUC, PEP/pubsub, stream management, push, federation and HTTP transports are implemented with limitations; see docs/COMPATIBILITY.md."
),
_ => bail!("invalid command; use --help"),
}
Ok(())
}
+690
View File
@@ -0,0 +1,690 @@
//! Personal XEP-0313 queries, preferences and RSM paging.
use crate::{
archive,
protocol::{self, ModuleResult},
storage::{self, LuaValue as V},
wire::escape,
};
use anyhow::{Context, Result, anyhow, bail};
use std::path::Path;
pub const NS: &str = "urn:xmpp:mam:2";
pub const EXTENDED_NS: &str = "urn:xmpp:mam:2#extended";
fn s(x: &str) -> V {
V::String(x.as_bytes().to_vec())
}
fn prep_jid(value: &str) -> Result<String> {
let bare = crate::account_modules::normalize_bare_jid(protocol::bare(value))?;
if let Some((_, resource)) = value.split_once('/') {
let resource = stringprep::resourceprep(resource).map_err(|e| anyhow!(e.to_string()))?;
anyhow::ensure!(
!resource.is_empty() && resource.len() <= 1023,
"invalid resource"
);
Ok(format!("{bare}/{resource}"))
} else {
Ok(bare)
}
}
fn policy(v: Option<&V>) -> &str {
match v {
Some(V::Bool(false)) => "never",
Some(V::String(_)) => "roster",
_ => "always",
}
}
fn prefs_xml(p: &V) -> String {
let mut yes = String::new();
let mut no = String::new();
if let V::Table(t) = p {
for (k, v) in t {
if let V::String(k) = k {
let j = format!("<jid>{}</jid>", escape(&String::from_utf8_lossy(k)));
if v == &V::Bool(true) {
yes.push_str(&j)
} else if v == &V::Bool(false) {
no.push_str(&j)
}
}
}
}
format!(
"<prefs xmlns='{NS}' default='{}'><always>{yes}</always><never>{no}</never></prefs>",
policy(p.get_key(&V::Bool(false)))
)
}
/// Apply stored personal policy. default_policy is always/never/roster from config.
pub fn should_archive(
root: &Path,
domain: &str,
user: &str,
peer: &str,
default_policy: &str,
) -> Result<bool> {
should_archive_with_options(root, domain, user, peer, default_policy, false)
}
pub fn should_archive_with_options(
root: &Path,
domain: &str,
user: &str,
peer: &str,
default_policy: &str,
smart_enable: bool,
) -> Result<bool> {
let stored = storage::load(root, domain, Some(user), "archive_prefs")?;
if smart_enable && stored.is_none() {
return Ok(false);
}
let p = stored.unwrap_or_default();
if let Some(V::Bool(b)) = p.get(peer) {
return Ok(*b);
}
let pol = if p.get_key(&V::Bool(false)).is_some() {
policy(p.get_key(&V::Bool(false)))
} else {
default_policy
};
Ok(match pol {
"never" => false,
"roster" => storage::load(root, domain, Some(user), "roster")?
.and_then(|r| r.get(peer).cloned())
.is_some(),
_ => true,
})
}
pub fn handle(
root: &Path,
domain: &str,
jid: &str,
iq: roxmltree::Node<'_, '_>,
) -> Result<Option<ModuleResult>> {
handle_with_policy(root, domain, jid, iq, "always")
}
pub fn handle_with_policy(
root: &Path,
domain: &str,
jid: &str,
iq: roxmltree::Node<'_, '_>,
default_policy: &str,
) -> Result<Option<ModuleResult>> {
handle_with_options(root, domain, jid, iq, default_policy, false)
}
pub fn handle_with_options(
root: &Path,
domain: &str,
jid: &str,
iq: roxmltree::Node<'_, '_>,
default_policy: &str,
smart_enable: bool,
) -> Result<Option<ModuleResult>> {
let Some(q) = iq
.children()
.find(|n| n.is_element() && n.tag_name().namespace() == Some(NS))
else {
return Ok(None);
};
let own = protocol::bare(jid);
if let Some(to) = iq.attribute("to")
&& to != own
&& to != domain
{
return Ok(Some(ModuleResult::reply(protocol::iq_error(
iq,
"forbidden",
))));
}
let Some((user, host)) = own.split_once('@') else {
return Ok(Some(ModuleResult::reply(protocol::iq_error(
iq,
"forbidden",
))));
};
if host != domain {
return Ok(Some(ModuleResult::reply(protocol::iq_error(
iq,
"forbidden",
))));
}
if smart_enable
&& matches!(q.tag_name().name(), "query" | "prefs")
&& matches!(iq.attribute("type"), Some("get" | "set"))
&& storage::load(root, domain, Some(user), "archive_prefs")?.is_none()
{
storage::update(root, domain, Some(user), "archive_prefs", |p| {
if p.entries().is_empty() {
p.set_key(
V::Bool(false),
match default_policy {
"never" => V::Bool(false),
"roster" => s("roster"),
_ => V::Bool(true),
},
);
}
Ok(())
})?;
}
match process(root, domain, user, own, iq, q, default_policy) {
Ok(v) => Ok(Some(v)),
Err(e) => {
let condition = if e.to_string() == "item-not-found" {
"item-not-found"
} else if e.to_string().starts_with("MAM archive read failed")
|| e.to_string().starts_with("MAM preferences storage failed")
{
"internal-server-error"
} else {
"bad-request"
};
let xml = protocol::iq_error(iq, condition);
let xml = if condition == "internal-server-error" {
xml
} else {
xml.replace("<error type='cancel'>", "<error type='modify'>")
};
Ok(Some(ModuleResult::reply(xml)))
}
}
}
fn process(
root: &Path,
domain: &str,
user: &str,
own: &str,
iq: roxmltree::Node<'_, '_>,
q: roxmltree::Node<'_, '_>,
default_policy: &str,
) -> Result<ModuleResult> {
let typ = iq.attribute("type").unwrap_or("");
if q.tag_name().name() == "prefs" {
let p = if typ == "set" {
let mut p = V::default();
let d = match q.attribute("default") {
Some("always") => V::Bool(true),
Some("never") => V::Bool(false),
Some("roster") => s("roster"),
_ => bail!("invalid policy"),
};
p.set_key(V::Bool(false), d);
for group in q.children().filter(|n| n.is_element()) {
let value = match group.tag_name().name() {
"always" => true,
"never" => false,
_ => bail!("invalid prefs"),
};
for j in group.children().filter(|n| n.is_element()) {
if j.tag_name().name() != "jid" {
bail!("invalid jid field")
};
let jid = j.text().ok_or_else(|| anyhow!("missing jid"))?;
if jid.is_empty() || jid.contains(char::is_whitespace) {
bail!("invalid jid")
};
p.set(&prep_jid(jid)?, V::Bool(value));
}
}
storage::save(root, domain, Some(user), "archive_prefs", &p)
.context("MAM preferences storage failed")?;
p
} else if typ == "get" {
{
let mut p = storage::load(root, domain, Some(user), "archive_prefs")
.context("MAM preferences storage failed")?
.unwrap_or_default();
if p.get_key(&V::Bool(false)).is_none() {
p.set_key(
V::Bool(false),
match default_policy {
"never" => V::Bool(false),
"roster" => s("roster"),
_ => V::Bool(true),
},
);
}
p
}
} else {
bail!("invalid IQ")
};
return Ok(ModuleResult::reply(protocol::iq_result(iq, &prefs_xml(&p))));
}
handle_archive_query(root, domain, Some(user), "archive", own, iq, q)
}
/// Caller MUST authorize access before invoking (e.g. MUC membership).
pub fn handle_archive_query(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
archive_jid: &str,
iq: roxmltree::Node<'_, '_>,
q: roxmltree::Node<'_, '_>,
) -> Result<ModuleResult> {
handle_archive_query_with_options(root, host, user, store, archive_jid, (iq, q), true)
}
/// Room archives can suppress optional totals without altering personal MAM defaults.
pub fn handle_archive_query_with_options(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
archive_jid: &str,
request: (roxmltree::Node<'_, '_>, roxmltree::Node<'_, '_>),
include_total: bool,
) -> Result<ModuleResult> {
let (iq, q) = request;
let typ = iq.attribute("type").unwrap_or("");
if q.tag_name().name() == "metadata" && typ == "get" {
let mut first = None;
let mut last = None;
archive::scan_store(root, host, user, store, |e| {
if first.is_none() {
first = Some((e.id.clone(), e.when));
}
last = Some((e.id, e.when));
Ok(())
})
.context("MAM archive read failed")?;
let mut payload = format!("<metadata xmlns='{NS}'>");
if let Some((id, when)) = first {
payload.push_str(&format!(
"<start id='{}' timestamp='{}'/>",
escape(&id),
archive::timestamp(when)?
));
}
if let Some((id, when)) = last {
payload.push_str(&format!(
"<end id='{}' timestamp='{}'/>",
escape(&id),
archive::timestamp(when)?
));
}
payload.push_str("</metadata>");
return Ok(ModuleResult::reply(protocol::iq_result(iq, &payload)));
}
if q.tag_name().name() != "query" {
return Ok(ModuleResult::reply(protocol::iq_error(
iq,
"feature-not-implemented",
)));
}
if typ == "get" {
return Ok(ModuleResult::reply(protocol::iq_result(
iq,
&format!(
"<query xmlns='{NS}'><x xmlns='jabber:x:data' type='form'><field var='FORM_TYPE' type='hidden'><value>{NS}</value></field><field var='with' type='jid-single'/><field var='start' type='text-single'/><field var='end' type='text-single'/><field var='before-id' type='text-single'/><field var='after-id' type='text-single'/><field var='ids' type='list-multi'/></x></query>"
),
)));
}
if typ != "set" {
bail!("invalid query")
}
// mod_muc_mam indexes stanza kind in the archive's `with` column.
let mut peer = (store == "muc_log").then(|| "message<groupchat".to_owned());
let mut start = None;
let mut end = None;
let mut after = None;
let mut before = None;
let mut ids = None;
if let Some(form) = q
.children()
.find(|n| n.has_tag_name(("jabber:x:data", "x")))
{
if form.attribute("type") != Some("submit") {
bail!("invalid form")
};
let mut form_type = false;
let mut seen = std::collections::HashSet::new();
for f in form
.children()
.filter(|n| n.has_tag_name(("jabber:x:data", "field")))
{
let var = f.attribute("var").unwrap_or("");
anyhow::ensure!(seen.insert(var), "duplicate query form field");
anyhow::ensure!(
var == "ids"
|| f.children()
.filter(|n| n.has_tag_name(("jabber:x:data", "value")))
.count()
<= 1,
"multiple values in single-valued query field"
);
let value = f
.children()
.find(|n| n.has_tag_name(("jabber:x:data", "value")))
.and_then(|n| n.text())
.unwrap_or("");
match f.attribute("var").unwrap_or("") {
"FORM_TYPE" => {
if value != NS {
bail!("wrong form type")
};
form_type = true
}
"with" => {
if store != "muc_log" && !value.is_empty() {
peer = Some(protocol::bare(&prep_jid(value)?).to_owned())
}
}
"start" => {
if !value.is_empty() {
start = Some(archive::parse_time(value)?)
}
}
"end" => {
if !value.is_empty() {
end = Some(archive::parse_time(value)?)
}
}
"before-id" => {
if !value.is_empty() {
before = Some(value.to_owned());
}
}
"after-id" => {
if !value.is_empty() {
after = Some(value.to_owned());
}
}
"ids" => {
ids = Some(
f.children()
.filter(|n| n.has_tag_name(("jabber:x:data", "value")))
.map(|n| n.text().unwrap_or("").to_owned())
.collect::<std::collections::HashSet<_>>(),
);
}
_ => bail!("unsupported field"),
}
}
if !form_type {
bail!("missing form type")
}
}
if start.zip(end).is_some_and(|(s, e)| s > e) {
bail!("invalid interval")
}
// Native internal storage omits optional totals; avoid a misleading base-set
// count/index when form ID boundaries further restrict the query.
let omit_totals = !include_total || before.is_some() || after.is_some();
let mut max = 20usize;
let mut reverse = false;
if let Some(set) = q
.children()
.find(|n| n.has_tag_name(("http://jabber.org/protocol/rsm", "set")))
{
for f in set.children().filter(|n| n.is_element()) {
match f.tag_name().name() {
"max" => max = f.text().unwrap_or("").parse::<usize>()?.min(50),
"after" => after = Some(f.text().unwrap_or("").to_owned()),
"before" => {
reverse = true;
before = Some(f.text().unwrap_or("").to_owned());
}
_ => bail!("unsupported RSM field"),
}
}
}
let mut page = std::collections::VecDeque::new();
let mut total = 0usize;
let mut eligible = 0usize;
let mut after_found = after.is_none();
let mut before_found = before.as_ref().is_none_or(|id| id.is_empty());
let mut before_reached = false;
archive::scan_store(root, host, user, store, |e| {
if peer.as_ref().is_some_and(|p| p != &e.peer)
|| start.is_some_and(|s| e.when < s)
|| end.is_some_and(|t| e.when > t)
|| ids.as_ref().is_some_and(|ids| !ids.contains(&e.id))
{
return Ok(());
}
let index = total;
total += 1;
let is_after = after.as_ref() == Some(&e.id);
let passed_after = after_found;
if is_after {
after_found = true;
}
if before
.as_ref()
.is_some_and(|id| !id.is_empty() && id == &e.id)
{
before_found = true;
before_reached = true;
return Ok(());
}
if !passed_after || is_after {
return Ok(());
}
if before_reached {
return Ok(());
}
eligible += 1;
if reverse {
if max > 0 {
page.push_back((index, e));
if page.len() > max {
page.pop_front();
}
}
} else if page.len() < max {
page.push_back((index, e));
}
Ok(())
})
.context("MAM archive read failed")?;
if !after_found || !before_found {
bail!("item-not-found")
}
let a = page.front().map(|(i, _)| *i).unwrap_or(0);
let complete = eligible <= max;
let queryid = q
.attribute("queryid")
.map(|v| format!(" queryid='{}'", escape(v)))
.unwrap_or_default();
let mut result = ModuleResult::default();
let flip = q.children().any(|n| n.has_tag_name((NS, "flip-page")));
let ordered: Box<dyn Iterator<Item = &(usize, archive::Entry)>> = if flip {
Box::new(page.iter().rev())
} else {
Box::new(page.iter())
};
for (_, e) in ordered {
let doc = roxmltree::Document::parse(&e.xml)?;
let forwarded = match doc.root_element().tag_name().namespace() {
Some("jabber:client") => e.xml.clone(),
None => {
anyhow::ensure!(
doc.root_element().tag_name().name() == "message",
"MAM archive contains a non-message stanza"
);
e.xml
.replacen("<message", "<message xmlns='jabber:client'", 1)
}
_ => {
let mut stored = storage::stanza_value(doc.root_element());
let mut attrs = stored.get("attr").cloned().unwrap_or_default();
attrs.set("xmlns", "jabber:client".into());
stored.set("attr", attrs);
storage::stanza_xml(&stored)?
}
};
result.replies.push(format!("<message xmlns='jabber:client' from='{}'><result xmlns='{NS}' id='{}'{queryid}><forwarded xmlns='urn:xmpp:forward:0'><delay xmlns='urn:xmpp:delay' stamp='{}'/>{}</forwarded></result></message>",escape(archive_jid),escape(&e.id),archive::timestamp(e.when)?,forwarded));
}
let bounds = page
.front()
.zip(page.back())
.map(|((_, f), (_, l))| {
let index = if omit_totals {
String::new()
} else {
format!(" index='{a}'")
};
format!(
"<first{index}>{}</first><last>{}</last>",
escape(&f.id),
escape(&l.id)
)
})
.unwrap_or_default();
let count = if omit_totals {
String::new()
} else {
format!("<count>{total}</count>")
};
result.replies.push(protocol::iq_result(iq,&format!("<fin xmlns='{NS}' complete='{complete}' stable='true'><set xmlns='http://jabber.org/protocol/rsm'>{bounds}{count}</set></fin>")));
Ok(result)
}
/// Exact default message selection from Prosody 13 mod_mam, before user preferences.
pub fn should_store(stanza: roxmltree::Node<'_, '_>) -> bool {
if !stanza.has_tag_name(("jabber:client", "message")) && stanza.tag_name().name() != "message" {
return false;
}
match stanza.attribute("type").unwrap_or("normal") {
"headline" | "groupchat" => return false,
"error" => return stanza.attribute("to").is_some_and(|j| j.contains('/')),
_ => (),
}
let has = |ns: &str, name: &str| stanza.children().any(|n| n.has_tag_name((ns, name)));
if has("urn:xmpp:hints", "no-store") || has("urn:xmpp:hints", "no-permanent-store") {
return false;
}
if has("urn:xmpp:hints", "store") {
return true;
}
stanza.children().filter(|n| n.is_element()).any(|n| {
let ns = n.tag_name().namespace().unwrap_or("");
let name = n.tag_name().name();
((name == "body" || name == "subject") && (ns == "jabber:client" || ns.is_empty()))
|| (ns == "urn:xmpp:eme:0" && name == "encryption")
|| matches!(
ns,
"urn:xmpp:receipts"
| "urn:xmpp:chat-markers:0"
| "urn:xmpp:jingle-message:0"
| "urn:xmpp:jingle-message:1"
)
|| (ns == "jabber:x:conference" && name == "x")
|| (ns == "http://jabber.org/protocol/muc#user"
&& name == "x"
&& n.children().any(|c| c.has_tag_name((ns, "invite"))))
})
}
fn retain_stanza_children(value: &mut V, mut keep: impl FnMut(&V) -> bool) {
let mut children = vec![];
let mut i = 1;
while let Some(child) = value.get_key(&V::Integer(i)) {
if keep(child) {
children.push(child.clone());
}
i += 1;
}
if let V::Table(fields) = value {
fields.retain(|(k, _)| !matches!(k, V::Integer(_)));
}
for (i, child) in children.into_iter().enumerate() {
value.set_key(V::Integer(i as i64 + 1), child);
}
}
fn sid_belongs_to(by: &str, domain: &str, owner: &str) -> bool {
if by.contains('/') {
return false;
}
let Some((user, host)) = by.split_once('@') else {
return false;
};
let Ok(user) = stringprep::nodeprep(user) else {
return false;
};
let Ok(host) = stringprep::nameprep(host) else {
return false;
};
let Ok(expected_host) = stringprep::nameprep(domain) else {
return false;
};
user == owner && host == expected_host
}
fn strip_owner_sid_value(value: &mut V, domain: &str, owner: &str) {
retain_stanza_children(value, |child| {
let attr = child.get("attr");
!(child.get("name").and_then(V::as_str) == Some("stanza-id")
&& attr.and_then(|a| a.get("xmlns")).and_then(V::as_str) == Some("urn:xmpp:sid:0")
&& attr
.and_then(|a| a.get("by"))
.and_then(V::as_str)
.is_some_and(|by| sid_belongs_to(by, domain, owner)))
});
}
/// Remove SIDs claiming this user's archive before routing out of their account.
/// This also removes forged SIDs submitted by clients. Other issuers are preserved.
pub fn strip_owner_stanza_ids(xml: &str, domain: &str, owner: &str) -> Result<String> {
let doc = roxmltree::Document::parse(xml)?;
let mut value = storage::stanza_value(doc.root_element());
strip_owner_sid_value(&mut value, domain, owner);
storage::stanza_xml(&value)
}
/// Store the sanitized message and return a delivery copy bearing its real archive ID.
/// The newly generated SID is not embedded in the archived stanza itself.
/// Default Prosody chat-state namespace stripping applies only to persisted content.
pub fn archive_for_user(
root: &Path,
domain: &str,
owner: &str,
peer: &str,
xml: &str,
time: f64,
) -> Result<(String, String)> {
archive_for_user_with_namespaces(
root,
domain,
owner,
peer,
xml,
time,
&["http://jabber.org/protocol/chatstates"],
)
}
pub fn archive_for_user_with_namespaces(
root: &Path,
domain: &str,
owner: &str,
peer: &str,
xml: &str,
time: f64,
strip_namespaces: &[&str],
) -> Result<(String, String)> {
let doc = roxmltree::Document::parse(xml)?;
anyhow::ensure!(
doc.root_element().tag_name().name() == "message",
"only message stanzas can be archived for a user"
);
let mut delivery = storage::stanza_value(doc.root_element());
strip_owner_sid_value(&mut delivery, domain, owner);
let mut persisted = delivery.clone();
retain_stanza_children(&mut persisted, |child| {
!child
.get("attr")
.and_then(|a| a.get("xmlns"))
.and_then(V::as_str)
.is_some_and(|ns| strip_namespaces.contains(&ns))
});
let stored_xml = storage::stanza_xml(&persisted)?;
let id =
archive::archive_message(root, domain, owner, protocol::bare(peer), &stored_xml, time)?;
let mut sid = V::default();
sid.set("name", "stanza-id".into());
let mut attr = V::default();
attr.set("xmlns", "urn:xmpp:sid:0".into());
attr.set("by", format!("{owner}@{domain}").into());
attr.set("id", id.clone().into());
sid.set("attr", attr);
let mut index = 1;
while delivery.get_key(&V::Integer(index)).is_some() {
index += 1;
}
delivery.set_key(V::Integer(index), sid);
Ok((id, storage::stanza_xml(&delivery)?))
}
+2506
View File
File diff suppressed because it is too large. Load diff
+204
View File
@@ -0,0 +1,204 @@
//! Bounded, live XEP-0045 discussion history, independent of archive storage.
use std::collections::VecDeque;
const MUC: &str = "http://jabber.org/protocol/muc";
/// Constraints apply together; an explicit constraint overrides the default count.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct HistoryRequest {
pub maxchars: Option<usize>,
pub maxstanzas: Option<usize>,
pub since: Option<f64>,
}
impl HistoryRequest {
pub fn parse(presence: &str, now: i64) -> Self {
let Ok(doc) = roxmltree::Document::parse(presence) else {
return Self::default();
};
let history = doc
.root_element()
.children()
.find(|n| n.has_tag_name((MUC, "x")))
.and_then(|x| x.children().find(|n| n.has_tag_name((MUC, "history"))));
let Some(history) = history else {
return Self::default();
};
let number = |name| {
history
.attribute(name)
.and_then(|v| v.parse::<f64>().ok())
.filter(|n| n.is_finite())
};
// Native tonumber accepts fractional/negative values: limits below one
// select no stanzas, and a fractional count is effectively rounded down.
let count = |name| number(name).map(|n| n.max(0.0).min(usize::MAX as f64) as usize);
let mut since = history
.attribute("since")
.and_then(|v| chrono::DateTime::parse_from_rfc3339(v).ok())
.map(|t| t.timestamp() as f64 + f64::from(t.timestamp_subsec_nanos()) / 1e9);
if let Some(seconds) = number("seconds") {
let cutoff = now as f64 - seconds;
since = Some(since.map_or(cutoff, |old| old.max(cutoff)));
}
Self {
maxchars: count("maxchars"),
maxstanzas: count("maxstanzas"),
since,
}
}
pub fn has_constraints(&self) -> bool {
self.maxchars.is_some() || self.maxstanzas.is_some() || self.since.is_some()
}
}
#[derive(Clone, Debug)]
struct Entry {
stanza: String,
timestamp: i64,
}
#[derive(Clone, Debug)]
pub struct RoomHistory {
capacity: usize,
default_messages: usize,
entries: VecDeque<Entry>,
}
impl RoomHistory {
pub fn new(capacity: usize) -> Self {
Self {
capacity,
default_messages: 20,
entries: VecDeque::new(),
}
}
pub fn set_capacity(&mut self, capacity: usize) {
self.capacity = capacity;
while self.entries.len() > capacity {
self.entries.pop_front();
}
}
pub fn set_default_messages(&mut self, count: usize) {
self.default_messages = count;
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Add one room broadcast, before per-recipient routing. Subject-only changes
/// are sent separately on join and do not consume discussion history slots.
pub fn push(&mut self, stanza: &str, timestamp: i64) {
if self.capacity == 0 {
return;
}
let Ok(doc) = roxmltree::Document::parse(stanza) else {
return;
};
let root = doc.root_element();
if !is_historic(root) {
return;
}
let from = root.attribute("from").unwrap_or("");
let room = from.split('/').next().unwrap_or(from);
let Some(time) = chrono::DateTime::from_timestamp(timestamp, 0) else {
return;
};
let stamp = time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let mut packet = crate::protocol::address(root, from, "");
let Some(end) = packet.rfind("</") else {
return;
};
packet.insert_str(
end,
&format!(
"<delay xmlns='urn:xmpp:delay' from='{}' stamp='{stamp}'/>",
crate::wire::escape(room)
),
);
self.entries.push_back(Entry {
stanza: packet,
timestamp,
});
self.set_capacity(self.capacity);
}
/// The live snapshot keeps only the last message, matching native MUC state.
pub fn last_message(&self) -> Option<(&str, i64)> {
self.entries
.back()
.map(|entry| (entry.stanza.as_str(), entry.timestamp))
}
/// Restore a snapshot stanza that already carries its original delay.
pub fn restore_last_message(&mut self, stanza: &str, timestamp: i64) {
if self.capacity == 0 {
return;
}
let Ok(doc) = roxmltree::Document::parse(stanza) else {
return;
};
if !is_historic(doc.root_element()) {
return;
}
self.entries.clear();
self.entries.push_back(Entry {
stanza: stanza.into(),
timestamp,
});
}
pub fn replay(&self, _room: &str, to: &str, presence: &str, now: i64) -> Vec<String> {
let request = HistoryRequest::parse(presence, now);
let max = request.maxstanzas.unwrap_or_else(|| {
if request.has_constraints() {
self.entries.len()
} else {
self.default_messages
}
});
let mut packets = Vec::new();
let mut chars = 0usize;
for entry in self.entries.iter().rev().take(max) {
if request
.since
.is_some_and(|since| (entry.timestamp as f64) < since)
{
break;
}
let Ok(doc) = roxmltree::Document::parse(&entry.stanza) else {
continue;
};
let root = doc.root_element();
let packet = crate::protocol::address(root, root.attribute("from").unwrap_or(""), to);
chars = chars.saturating_add(packet.len());
if request.maxchars.is_some_and(|limit| chars > limit) {
break;
}
packets.push(packet);
}
packets.reverse();
packets
}
}
fn is_historic(stanza: roxmltree::Node<'_, '_>) -> bool {
if stanza.tag_name().name() != "message" || stanza.attribute("type") != Some("groupchat") {
return false;
}
let has =
|name: &str, namespace: &str| stanza.children().any(|n| n.has_tag_name((namespace, name)));
if has("no-store", "urn:xmpp:hints") || has("no-permanent-store", "urn:xmpp:hints") {
return false;
}
has("store", "urn:xmpp:hints")
|| stanza.children().any(|n| {
n.is_element()
&& n.tag_name().name() == "body"
&& matches!(n.tag_name().namespace(), None | Some("jabber:client"))
})
|| has("encryption", "urn:xmpp:eme:0")
|| stanza
.children()
.any(|n| n.is_element() && n.tag_name().namespace() == Some("urn:xmpp:chat-markers:0"))
}
+502
View File
@@ -0,0 +1,502 @@
//! Bounded native Prosody MUC occupant checkpoints.
//!
//! A checkpoint describes prior membership, never a live transport. Callers must
//! keep recovered occupants out of routing until authenticated rejoin and current
//! room admission checks succeed. Saved roles are hints, not authorization.
use crate::{
protocol::bare,
storage::{self, LuaValue as V},
};
use anyhow::{Result, ensure};
use std::{collections::BTreeMap, path::Path};
pub const MAX_OCCUPANTS: usize = 4096;
const MAX_FIELD: usize = 3072;
const MAX_PRESENCE: usize = 16 * 1024;
pub const RECOVERY_SECONDS: u64 = 300;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OccupantSnapshot {
pub jid: String,
pub nick: String,
pub role: String,
/// Only show/status/priority are retained. Passwords and extension payloads
/// are intentionally excluded, including when reading native snapshots.
pub presence_payload: String,
}
fn valid(s: &str) -> bool {
!s.is_empty() && s.len() <= MAX_FIELD && !s.chars().any(char::is_control)
}
fn room_node<'a>(room: &'a str, domain: &str) -> Result<&'a str> {
let (node, host) = room.split_once('@').unwrap_or(("", ""));
ensure!(
valid(node) && host == domain && !room.contains('/'),
"invalid recovery room"
);
Ok(node)
}
fn field<'a>(v: &'a V, key: &str) -> Option<&'a str> {
v.get(key)?.as_str()
}
fn scalar_stanza(name: &str, text: &str) -> V {
let mut v = V::table();
v.set("name", name.into());
v.set("attr", V::table());
v.set_key(V::Integer(1), text.into());
v
}
fn safe_payload(v: &V) -> String {
let mut out = String::new();
for (_, child) in v.entries() {
let Some(name) = field(child, "name") else {
continue;
};
if !matches!(name, "show" | "status" | "priority") {
continue;
}
if child
.get("attr")
.and_then(|a| field(a, "xmlns"))
.is_some_and(|ns| ns != "jabber:client")
{
continue;
}
let text = child
.get_key(&V::Integer(1))
.and_then(V::as_str)
.unwrap_or("");
if text.len() <= MAX_FIELD && out.len() + text.len() + 64 <= MAX_PRESENCE {
out.push_str(&format!("<{name}>{}</{name}>", crate::wire::escape(text)));
}
}
out
}
/// Decode Prosody's state store (and old frozen room maps) without trusting roles,
/// primary-session pointers, or unvalidated stanza extensions.
pub fn decode_room(room: &str, value: &V) -> Result<Vec<OccupantSnapshot>> {
ensure!(valid(room) && !room.contains('/'), "invalid recovery room");
ensure!(
value.entries().len() <= MAX_OCCUPANTS * 2 + 16,
"occupant recovery limit"
);
let prefix = format!("{room}/");
let mut out = BTreeMap::new();
for (key, presence) in value.entries() {
let Some(jid) = key.as_str() else { continue };
if !valid(jid)
|| !jid.contains('/')
|| jid.starts_with(&prefix)
|| field(presence, "name") != Some("presence")
{
continue;
}
let Some(attr) = presence.get("attr") else {
continue;
};
if field(attr, "type").is_some() {
continue;
}
let Some(occupant_jid) = field(attr, "from") else {
continue;
};
let Some(nick) = occupant_jid.strip_prefix(&prefix).filter(|s| valid(s)) else {
continue;
};
let Some(meta) = value.get(occupant_jid) else {
continue;
};
if field(meta, "bare_jid") != Some(bare(jid)) {
continue;
}
let Some(role) =
field(meta, "role").filter(|r| matches!(*r, "visitor" | "participant" | "moderator"))
else {
continue;
};
out.insert(
jid.to_owned(),
OccupantSnapshot {
jid: jid.into(),
nick: nick.into(),
role: role.into(),
presence_payload: safe_payload(presence),
},
);
ensure!(out.len() <= MAX_OCCUPANTS, "occupant recovery limit");
}
Ok(out.into_values().collect())
}
pub fn load_room(root: &Path, domain: &str, room: &str) -> Result<Vec<OccupantSnapshot>> {
let node = room_node(room, domain)?;
match storage::load(root, domain, Some(node), "state")? {
Some(value) => decode_room(room, &value),
None => Ok(Vec::new()),
}
}
/// Write a Prosody-compatible state map atomically. Empty membership removes it.
pub fn save_room(
root: &Path,
domain: &str,
room: &str,
occupants: &[OccupantSnapshot],
) -> Result<()> {
save_room_with_fingerprint(root, domain, room, occupants, None)
}
/// The optional digest binds automatic password-room recovery to its saved config.
pub fn save_room_with_fingerprint(
root: &Path,
domain: &str,
room: &str,
occupants: &[OccupantSnapshot],
fingerprint: Option<&str>,
) -> Result<()> {
let saved_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
save_room_checkpoint(root, domain, room, occupants, fingerprint, saved_at)
}
pub fn save_room_checkpoint(
root: &Path,
domain: &str,
room: &str,
occupants: &[OccupantSnapshot],
fingerprint: Option<&str>,
saved_at: u64,
) -> Result<()> {
let node = room_node(room, domain)?;
ensure!(saved_at <= i64::MAX as u64, "invalid checkpoint timestamp");
ensure!(occupants.len() <= MAX_OCCUPANTS, "occupant recovery limit");
let mut state = V::table();
for o in occupants {
ensure!(
valid(&o.jid) && o.jid.contains('/') && valid(&o.nick),
"invalid occupant recovery identity"
);
ensure!(
matches!(o.role.as_str(), "visitor" | "participant" | "moderator"),
"invalid recovery role"
);
let occupant_jid = format!("{room}/{}", o.nick);
let mut meta = V::table();
meta.set("bare_jid", bare(&o.jid).into());
meta.set("jid", o.jid.clone().into());
meta.set("role", o.role.clone().into());
if let Some(previous) = state.get(&occupant_jid) {
ensure!(
field(previous, "bare_jid") == Some(bare(&o.jid)),
"duplicate occupant nickname"
);
}
state.set(&occupant_jid, meta);
let mut presence = V::table();
presence.set("name", "presence".into());
let mut attr = V::table();
attr.set("from", occupant_jid.into());
attr.set("to", o.jid.clone().into());
presence.set("attr", attr);
// Status text is optional checkpoint data. Large or unparseable
// client payloads must never prevent saving room membership at shutdown.
if o.presence_payload.len() <= MAX_PRESENCE {
let wrapped = format!("<presence>{}</presence>", o.presence_payload);
if let Ok(doc) = roxmltree::Document::parse(&wrapped) {
let mut index = 0;
for child in doc.root_element().children().filter(|c| c.is_element()) {
let name = child.tag_name().name();
if !matches!(name, "show" | "status" | "priority")
|| !matches!(child.tag_name().namespace(), None | Some("jabber:client"))
{
continue;
}
let text = child.text().unwrap_or("");
if text.len() > MAX_FIELD {
continue;
}
index += 1;
presence.set_key(V::Integer(index), scalar_stanza(name, text));
}
}
}
state.set(&o.jid, presence);
}
if !occupants.is_empty() {
state.set("_rust_xmpp_saved_at", V::Integer(saved_at as i64));
if let Some(fingerprint) = fingerprint {
ensure!(
fingerprint.len() == 64 && fingerprint.bytes().all(|b| b.is_ascii_hexdigit()),
"invalid recovery fingerprint"
);
state.set("_rust_xmpp_config_hash", fingerprint.into());
}
}
storage::save(root, domain, Some(node), "state", &state)
}
/// Dormant claims have a fixed restart deadline; probes never extend it.
#[derive(Default)]
pub struct PendingOccupants {
rooms: BTreeMap<String, (u64, Vec<OccupantSnapshot>)>,
}
impl PendingOccupants {
pub fn insert(
&mut self,
room: String,
occupants: Vec<OccupantSnapshot>,
now: u64,
) -> Result<()> {
ensure!(
occupants.len() <= MAX_OCCUPANTS
&& self.rooms.values().map(|(_, v)| v.len()).sum::<usize>() + occupants.len()
<= MAX_OCCUPANTS,
"pending recovery limit"
);
self.rooms
.insert(room, (now.saturating_add(RECOVERY_SECONDS), occupants));
Ok(())
}
/// Preserve a checkpoint's original deadline over repeated process restarts.
pub fn insert_checkpoint(
&mut self,
room: String,
occupants: Vec<OccupantSnapshot>,
saved_at: u64,
now: u64,
) -> Result<()> {
if saved_at > now || saved_at.saturating_add(RECOVERY_SECONDS) <= now {
return Ok(());
}
self.insert(room, occupants, saved_at)
}
/// Call only after authenticating the account and checking current room ACLs.
/// Resource names alone never establish ownership, and old privilege must be
/// recomputed by the caller rather than copied from the returned snapshot.
pub fn claim(
&mut self,
room: &str,
authenticated_jid: &str,
nick: &str,
now: u64,
) -> Option<OccupantSnapshot> {
self.expire(now);
let (_, occupants) = self.rooms.get_mut(room)?;
let i = occupants
.iter()
.position(|o| o.jid == authenticated_jid && o.nick == nick)?;
let mut claim = occupants.remove(i);
claim.jid = authenticated_jid.into();
Some(claim)
}
/// Candidate snapshots are still dormant. The caller rechecks admission
/// before claim, and must not route to these identities beforehand.
pub fn candidates(
&mut self,
authenticated_jid: &str,
now: u64,
) -> Vec<(String, OccupantSnapshot)> {
self.expire(now);
self.rooms
.iter()
.flat_map(|(room, (_, occupants))| {
occupants
.iter()
.filter(|o| o.jid == authenticated_jid)
.map(|o| (room.clone(), o.clone()))
})
.collect()
}
pub fn room_snapshot(&mut self, room: &str, now: u64) -> Option<(u64, Vec<OccupantSnapshot>)> {
self.expire(now);
self.rooms.get(room).map(|(deadline, occupants)| {
(deadline.saturating_sub(RECOVERY_SECONDS), occupants.clone())
})
}
/// Read-only membership probe; deadline-sensitive callers use contains_room.
pub fn has_room(&self, room: &str) -> bool {
self.rooms
.get(room)
.is_some_and(|(_, occupants)| !occupants.is_empty())
}
/// Live dormant identities for bounded admission accounting.
pub fn identities(&self, now: u64) -> Vec<(String, String)> {
self.rooms
.iter()
.filter(|(_, (deadline, _))| *deadline > now)
.flat_map(|(room, (_, occupants))| {
occupants.iter().map(|o| (room.clone(), o.jid.clone()))
})
.collect()
}
/// A dormant nickname remains owned by its authenticated bare account until
/// claim expiry; a reused resource belonging to another account cannot take it.
pub fn reserved_by(&self, room: &str, nick: &str, now: u64) -> Option<String> {
let (deadline, occupants) = self.rooms.get(room)?;
if *deadline <= now {
return None;
}
occupants
.iter()
.find(|o| o.nick == nick)
.map(|o| bare(&o.jid).to_owned())
}
pub fn contains_room(&mut self, room: &str, now: u64) -> bool {
self.expire(now);
self.rooms
.get(room)
.is_some_and(|(_, occupants)| !occupants.is_empty())
}
pub fn expire(&mut self, now: u64) -> usize {
let mut removed = 0;
self.rooms.retain(|_, (deadline, occupants)| {
if *deadline <= now {
removed += occupants.len();
false
} else {
true
}
});
removed
}
pub fn len(&self) -> usize {
self.rooms.values().map(|(_, v)| v.len()).sum()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
/// Hash canonical room contents. Lua table iteration order is not stable across
/// VM instances, so storage serialization order cannot be an authorization proof.
pub fn configuration_fingerprint(config: &V) -> Result<String> {
use sha2::{Digest, Sha256};
fn canonical(value: &V, depth: usize) -> Result<V> {
ensure!(depth < 64, "configuration fingerprint depth limit");
if let V::Table(entries) = value {
let mut sorted = entries
.iter()
.map(|(key, value)| {
Ok((
storage::serialize(key)?,
key.clone(),
canonical(value, depth + 1)?,
))
})
.collect::<Result<Vec<_>>>()?;
sorted.sort_by(|a, b| a.0.cmp(&b.0));
Ok(V::Table(
sorted
.into_iter()
.map(|(_, key, value)| (key, value))
.collect(),
))
} else {
Ok(value.clone())
}
}
Ok(hex::encode(Sha256::digest(
storage::serialize(&canonical(config, 0)?)?.as_bytes(),
)))
}
pub fn load_fingerprint(root: &Path, domain: &str, room: &str) -> Result<Option<String>> {
let node = room_node(room, domain)?;
Ok(storage::load(root, domain, Some(node), "state")?
.and_then(|v| field(&v, "_rust_xmpp_config_hash").map(str::to_owned)))
}
/// Native snapshots have no timestamp; callers should stamp them once before
/// placing them in dormant recovery so subsequent restarts retain the deadline.
pub fn load_checkpoint_time(root: &Path, domain: &str, room: &str) -> Result<Option<u64>> {
let node = room_node(room, domain)?;
Ok(storage::load(root, domain, Some(node), "state")?
.and_then(|v| v.get("_rust_xmpp_saved_at").and_then(V::as_i64))
.and_then(|time| u64::try_from(time).ok()))
}
fn validate_last_message(room: &str, stanza: &str) -> Result<()> {
ensure!(stanza.len() <= MAX_PRESENCE, "recovery last message limit");
let doc = roxmltree::Document::parse(stanza)?;
let root = doc.root_element();
ensure!(
root.tag_name().name() == "message"
&& matches!(
root.tag_name().namespace(),
None | Some("") | Some("jabber:client")
),
"invalid recovery message namespace"
);
ensure!(
root.attribute("type") == Some("groupchat"),
"invalid recovery message type"
);
let from = root.attribute("from").unwrap_or("");
ensure!(
from == room
|| from
.strip_prefix(room)
.is_some_and(|suffix| suffix.starts_with('/') && suffix.len() > 1),
"recovery message belongs to another room"
);
Ok(())
}
/// Preserve native Prosody's final history entry while keeping occupant state.
/// Call after saving the room checkpoint, which replaces the state map.
pub fn set_last_message(
root: &Path,
domain: &str,
room: &str,
message: Option<(&str, i64)>,
) -> Result<()> {
let node = room_node(room, domain)?;
let encoded = if let Some((stanza, timestamp)) = message {
ensure!(timestamp >= 0, "invalid recovery message timestamp");
validate_last_message(room, stanza)?;
let doc = roxmltree::Document::parse(stanza)?;
Some((storage::stanza_value(doc.root_element()), timestamp))
} else {
None
};
storage::update(root, domain, Some(node), "state", |state| {
state.set(
"_last_message",
encoded.as_ref().map(|(v, _)| v.clone()).unwrap_or(V::Nil),
);
state.set(
"_last_message_at",
encoded
.as_ref()
.map(|(_, time)| V::Integer(*time))
.unwrap_or(V::Nil),
);
Ok(())
})?;
Ok(())
}
pub fn load_last_message(root: &Path, domain: &str, room: &str) -> Result<Option<(String, i64)>> {
let node = room_node(room, domain)?;
let Some(state) = storage::load(root, domain, Some(node), "state")? else {
return Ok(None);
};
let (Some(message), Some(timestamp)) =
(state.get("_last_message"), state.get("_last_message_at"))
else {
return Ok(None);
};
let timestamp = match timestamp {
V::Integer(value) if *value >= 0 => *value,
V::Number(value) if value.is_finite() && *value >= 0.0 && *value < i64::MAX as f64 => {
*value as i64
}
_ => return Ok(None),
};
ensure!(
storage::serialize(message)?.len() <= MAX_PRESENCE * 2,
"recovery last message limit"
);
let stanza = storage::stanza_xml(message)?;
validate_last_message(room, &stanza)?;
Ok(Some((stanza, timestamp)))
}
+117
View File
@@ -0,0 +1,117 @@
//! Payload-shape diagnostics. Values are fixed classifications and counts;
//! account names, node names, item identifiers and key material stay private.
use std::collections::BTreeSet;
const EVENT: &str = "http://jabber.org/protocol/pubsub#event";
const LEGACY: &str = "eu.siacs.conversations.axolotl";
const OMEMO2: &str = "urn:xmpp:omemo:2";
/// Inspect the first direct item in an IQ or event `items` wrapper.
pub fn item_fields(items: roxmltree::Node<'_, '_>) -> Vec<(&'static str, String)> {
let payload = items
.children()
.find(|n| {
n.is_element()
&& n.tag_name().name() == "item"
&& n.tag_name().namespace() == items.tag_name().namespace()
})
.and_then(|n| n.first_element_child());
let protocol = match payload.map(|n| (n.tag_name().namespace(), n.tag_name().name())) {
Some((Some(LEGACY), "list")) => "legacy",
Some((Some(OMEMO2), "devices")) => "omemo2",
_ => "other",
};
let mut valid = BTreeSet::new();
let mut invalid = 0;
if protocol != "other" {
for device in payload
.unwrap()
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "device")
{
// Conversations' parser checks the local name, then uses Java
// Integer.valueOf; it does not trim or restrict IDs to positive values.
if let Some(id) = device.attribute("id").and_then(java_integer) {
valid.insert(id);
} else {
invalid += 1;
}
}
}
vec![
("device_protocol", protocol.into()),
("valid_device_count", valid.len().to_string()),
("invalid_device_count", invalid.to_string()),
]
}
/// Inspect outgoing PEP item notifications without disclosing stanza values.
pub fn event_fields(message: roxmltree::Node<'_, '_>) -> Option<Vec<(&'static str, String)>> {
if message.tag_name().name() != "message" {
return None;
}
let event = message
.children()
.find(|n| n.has_tag_name((EVENT, "event")))?;
let items = event
.children()
.find(|n| n.has_tag_name((EVENT, "items")))?;
let node_kind = match items.attribute("node") {
Some("eu.siacs.conversations.axolotl.devicelist" | "urn:xmpp:omemo:2:devices") => {
"omemo-devices"
}
Some("urn:xmpp:avatar:metadata") => "avatar-metadata",
Some("urn:xmpp:avatar:data") => "avatar-data",
_ => "other",
};
let mut direct_items = items.children().filter(|n| n.has_tag_name((EVENT, "item")));
let content_present = direct_items
.next()
.is_some_and(|n| n.first_element_child().is_some());
let items_count = items
.children()
.filter(|n| n.has_tag_name((EVENT, "item")))
.count();
let mut fields = vec![
("node_kind", node_kind.into()),
("items_count", items_count.to_string()),
("content_present", content_present.to_string()),
];
fields.extend(item_fields(items));
Some(fields)
}
// Java Integer.parseInt accepts BMP decimal digits through Character.digit(char,
// 10), but rejects supplementary digits because it processes UTF-16 code units.
fn java_integer(value: &str) -> Option<i32> {
let (negative, digits) = if let Some(v) = value.strip_prefix('-') {
(true, v)
} else {
(false, value.strip_prefix('+').unwrap_or(value))
};
if digits.is_empty() {
return None;
}
let limit = if negative {
2_147_483_648_i64
} else {
2_147_483_647
};
let mut number = 0_i64;
for digit in digits.chars() {
let code = digit as u32;
let zero = [
0x0030, 0x0660, 0x06f0, 0x07c0, 0x0966, 0x09e6, 0x0a66, 0x0ae6, 0x0b66, 0x0be6, 0x0c66,
0x0ce6, 0x0d66, 0x0de6, 0x0e50, 0x0ed0, 0x0f20, 0x1040, 0x1090, 0x17e0, 0x1810, 0x1946,
0x19d0, 0x1a80, 0x1a90, 0x1b50, 0x1bb0, 0x1c40, 0x1c50, 0xa620, 0xa8d0, 0xa900, 0xa9d0,
0xa9f0, 0xaa50, 0xabf0, 0xff10,
]
.into_iter()
.find(|zero| code >= *zero && code < *zero + 10)?;
number = number * 10 + i64::from(code - zero);
if number > limit {
return None;
}
}
Some(if negative { -number } else { number } as i32)
}
+251
View File
@@ -0,0 +1,251 @@
//! XEP-0379 presence tokens and the XEP-0401 contact invitation command.
//!
//! Unscoped tokens use Prosody mod_invites' per-user `invite_token` map
//! (token -> Unix expiry). Identity-scoped tokens live separately so a server
//! that does not understand the scope cannot accidentally redeem them.
use crate::{
account_modules::normalize_bare_jid,
protocol::{ModuleResult, bare, iq_error, iq_result},
storage::{self, LuaValue as V},
wire::escape,
};
use anyhow::{Result, ensure};
use roxmltree::Node;
use std::path::Path;
pub const NS: &str = "urn:xmpp:pars:0";
pub const INVITE_COMMAND: &str = "urn:xmpp:invite#invite";
pub const DEFAULT_TTL: i64 = 7 * 86400;
const SCOPED_STORE: &str = "preauth_scoped";
const MAX_TOKENS: usize = 256;
#[derive(Debug, Clone)]
pub struct Invitation {
pub token: String,
pub uri: String,
pub expires: i64,
}
fn local_user<'a>(domain: &str, owner: &'a str) -> Result<&'a str> {
let (user, host) = owner
.split_once('@')
.ok_or_else(|| anyhow::anyhow!("account JID required"))?;
ensure!(
host == domain && !user.is_empty() && !owner.contains('/'),
"not a local bare account"
);
ensure!(
normalize_bare_jid(owner)? == owner,
"noncanonical account JID"
);
Ok(user)
}
fn expiry(value: &V) -> Option<i64> {
match value {
V::Integer(n) => Some(*n),
V::Number(n) if n.is_finite() && n.fract() == 0.0 && *n >= 0.0 && *n < i64::MAX as f64 => {
Some(*n as i64)
}
_ => None,
}
}
/// Issue a single-use token. `owner` and optional `recipient` must be bare JIDs.
/// `now` is Unix seconds; callers normally use `chrono::Utc::now().timestamp()`.
pub fn create_invite(
root: &Path,
domain: &str,
owner: &str,
recipient: Option<&str>,
ttl: i64,
now: i64,
) -> Result<Invitation> {
let user = local_user(domain, owner)?;
ensure!(
storage::load(root, domain, Some(user), "accounts")?
.is_some_and(|account| account.get("disabled") != Some(&V::Bool(true))),
"unknown account"
);
ensure!(
now >= 0 && ttl > 0 && ttl <= 30 * 86400,
"invalid token lifetime"
);
let expires = now
.checked_add(ttl)
.ok_or_else(|| anyhow::anyhow!("expiry overflow"))?;
let recipient = recipient.map(normalize_bare_jid).transpose()?;
ensure!(recipient.as_deref() != Some(owner), "cannot invite self");
let token = uuid::Uuid::new_v4().simple().to_string();
let scoped = recipient.is_some();
let store = if scoped { SCOPED_STORE } else { "invite_token" };
storage::update(root, domain, Some(user), store, |data| {
let expired: Vec<_> = data
.entries()
.iter()
.filter_map(|(k, v)| {
let end = if scoped {
v.get("expires").and_then(expiry)
} else {
expiry(v)
};
end.filter(|e| *e <= now).map(|_| k.clone())
})
.collect();
for key in expired {
data.set_key(key, V::Nil);
}
ensure!(
data.entries().len() < MAX_TOKENS,
"too many pending invitations"
);
let value = if let Some(jid) = &recipient {
let mut value = V::table();
value.set("expires", V::Integer(expires));
value.set("jid", jid.clone().into());
value
} else {
V::Integer(expires)
};
data.set(&token, value);
Ok(())
})?;
Ok(Invitation {
uri: format!("xmpp:{owner}?roster;preauth={token}"),
token,
expires,
})
}
/// Atomically validate and consume a token. Invalid/expired/mismatched tokens
/// return false and must follow normal manual subscription processing.
/// Caller must authenticate `sender`, verify recipient account and blocking,
/// then apply roster transitions only when this returns true.
pub fn consume(
root: &Path,
domain: &str,
owner: &str,
sender: &str,
token: &str,
now: i64,
) -> Result<bool> {
let user = local_user(domain, owner)?;
let sender = normalize_bare_jid(bare(sender))?;
if token.is_empty() || token.len() > 256 || sender == owner || now < 0 {
return Ok(false);
}
let mut accepted = false;
// Never fall back to an unscoped record when a scoped record is present.
let mut found_scope = false;
if storage::load(root, domain, Some(user), SCOPED_STORE)?
.is_some_and(|data| data.get(token).is_some())
{
storage::update(root, domain, Some(user), SCOPED_STORE, |data| {
if let Some(value) = data.get(token) {
found_scope = true;
let end = value.get("expires").and_then(expiry);
accepted = end.is_some_and(|end| end > now)
&& value.get("jid").and_then(V::as_str) == Some(sender.as_str());
if accepted || end.is_some_and(|end| end <= now) {
data.set(token, V::Nil);
}
}
Ok(())
})?;
}
if found_scope {
return Ok(accepted);
}
if !storage::load(root, domain, Some(user), "invite_token")?
.is_some_and(|data| data.get(token).is_some())
{
return Ok(false);
}
storage::update(root, domain, Some(user), "invite_token", |data| {
if let Some(end) = data.get(token).and_then(expiry) {
accepted = end > now;
data.set(token, V::Nil);
}
Ok(())
})?;
if accepted
&& storage::load(root, domain, None, "invite_token")?
.is_some_and(|data| data.get(token).is_some())
{
// mod_invites also removes the host-level registration side of a
// combined roster/account invitation when the roster token is used.
storage::update(root, domain, None, "invite_token", |data| {
if data.get(token).is_some_and(|record| {
record.get("type").and_then(V::as_str) == Some("roster")
&& record.get("jid").and_then(V::as_str) == Some(owner)
}) {
data.set(token, V::Nil);
}
Ok(())
})?;
}
Ok(accepted)
}
/// Only a single, well-formed direct child of a subscribe presence qualifies.
pub fn token_from_presence<'a>(presence: Node<'a, '_>) -> Option<&'a str> {
if presence.tag_name().name() != "presence" || presence.attribute("type") != Some("subscribe") {
return None;
}
let mut tokens = presence
.children()
.filter(|n| n.has_tag_name((NS, "preauth")));
let token = tokens.next()?;
if tokens.next().is_some() || token.children().any(|n| n.is_element()) {
return None;
}
token
.attribute("token")
.filter(|t| !t.is_empty() && t.len() <= 256)
}
/// Contact-only subset of XEP-0401, with no registration permission or landing URL.
pub fn handle_iq(
root: &Path,
domain: &str,
actor: &str,
iq: Node<'_, '_>,
) -> Result<Option<ModuleResult>> {
let Some(command) = iq.children().find(|n| {
n.has_tag_name(("http://jabber.org/protocol/commands", "command"))
&& n.attribute("node") == Some(INVITE_COMMAND)
}) else {
return Ok(None);
};
let error = |name| Ok(Some(ModuleResult::reply(iq_error(iq, name))));
if iq.attribute("type") != Some("set")
|| iq.attribute("id").is_none()
|| iq.children().filter(|n| n.is_element()).count() != 1
{
return error("bad-request");
}
let owner = bare(actor);
if local_user(domain, owner).is_err() {
return error("not-authorized");
}
if iq
.attribute("to")
.is_some_and(|to| to != domain && to != owner)
{
return error("forbidden");
}
if !matches!(command.attribute("action"), None | Some("execute"))
|| command.attribute("sessionid").is_some()
|| command.children().any(|n| n.is_element())
{
return error("bad-request");
}
let now = chrono::Utc::now().timestamp();
let invitation = match create_invite(root, domain, owner, None, DEFAULT_TTL, now) {
Ok(invite) => invite,
Err(_) => return error("resource-constraint"),
};
let expires = chrono::DateTime::from_timestamp(invitation.expires, 0)
.ok_or_else(|| anyhow::anyhow!("invalid expiry"))?
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let payload = format!(
"<command xmlns='http://jabber.org/protocol/commands' node='{INVITE_COMMAND}' status='completed'><x xmlns='jabber:x:data' type='result'><field var='FORM_TYPE' type='hidden'><value>urn:xmpp:invite#invitation</value></field><field var='uri' type='text-single'><value>{}</value></field><field var='expire' type='text-single'><value>{}</value></field></x></command>",
escape(&invitation.uri),
escape(&expires)
);
Ok(Some(ModuleResult::reply(iq_result(iq, &payload))))
}
+102
View File
@@ -0,0 +1,102 @@
use crate::wire::escape;
#[derive(Default, Debug)]
pub struct ModuleResult {
pub replies: Vec<String>,
/// (destination full or bare JID, serialized stanza)
pub deliveries: Vec<(String, String)>,
}
impl ModuleResult {
pub fn reply(xml: String) -> Self {
Self {
replies: vec![xml],
deliveries: vec![],
}
}
}
pub fn iq_result(iq: roxmltree::Node<'_, '_>, payload: &str) -> String {
let id = escape(iq.attribute("id").unwrap_or(""));
let from = iq
.attribute("to")
.map(|v| format!(" from='{}'", escape(v)))
.unwrap_or_default();
format!("<iq xmlns='jabber:client' type='result' id='{id}'{from}>{payload}</iq>")
}
pub fn iq_error(iq: roxmltree::Node<'_, '_>, condition: &str) -> String {
let id = escape(iq.attribute("id").unwrap_or(""));
let from = iq
.attribute("to")
.map(|v| format!(" from='{}'", escape(v)))
.unwrap_or_default();
let payload = iq.first_element_child().map(xml).unwrap_or_default();
format!(
"<iq xmlns='jabber:client' type='error' id='{id}'{from}>{payload}<error type='cancel'><{condition} xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></error></iq>"
)
}
/// Re-serialize a subtree, explicitly preserving namespace bindings.
pub fn xml(node: roxmltree::Node<'_, '_>) -> String {
xml_namespace(node, None)
}
pub fn xml_namespace(node: roxmltree::Node<'_, '_>, mapping: Option<(&str, &str)>) -> String {
serialize(node, mapping, None)
}
pub fn address(node: roxmltree::Node<'_, '_>, from: &str, to: &str) -> String {
serialize(node, None, Some((from, to)))
}
fn serialize(
node: roxmltree::Node<'_, '_>,
mapping: Option<(&str, &str)>,
addresses: Option<(&str, &str)>,
) -> String {
if node.is_text() {
return escape(node.text().unwrap_or(""));
}
if !node.is_element() {
return String::new();
}
let tag = node.tag_name();
let prefix = tag.namespace().and_then(|ns| node.lookup_prefix(ns));
let name = prefix
.map(|p| format!("{p}:{}", tag.name()))
.unwrap_or_else(|| tag.name().to_owned());
let mut out = format!("<{name}");
for ns in node.namespaces() {
let uri = if let Some((from, to)) = mapping {
if ns.uri() == from { to } else { ns.uri() }
} else {
ns.uri()
};
if ns.name() == Some("xml") {
continue;
}
match ns.name() {
Some(p) => out.push_str(&format!(" xmlns:{p}='{}'", escape(uri))),
None => out.push_str(&format!(" xmlns='{}'", escape(uri))),
}
}
for a in node.attributes() {
if addresses.is_some() && a.namespace().is_none() && matches!(a.name(), "from" | "to") {
continue;
}
let name = match a.namespace() {
Some("http://www.w3.org/XML/1998/namespace") => format!("xml:{}", a.name()),
Some(ns) => node
.lookup_prefix(ns)
.map(|p| format!("{p}:{}", a.name()))
.unwrap_or_else(|| a.name().into()),
None => a.name().into(),
};
out.push_str(&format!(" {name}='{}'", escape(a.value())));
}
if let Some((from, to)) = addresses {
out.push_str(&format!(" from='{}' to='{}'", escape(from), escape(to)));
}
out.push('>');
for child in node.children() {
out.push_str(&serialize(child, mapping, None));
}
out.push_str(&format!("</{name}>"));
out
}
pub fn bare(jid: &str) -> &str {
jid.split('/').next().unwrap_or(jid)
}
+1608
View File
File diff suppressed because it is too large. Load diff
+451
View File
@@ -0,0 +1,451 @@
//! XEP-0357 registrations and notifications in Prosody's cloud_notify store.
use crate::{
config::Config,
protocol::{self, ModuleResult},
storage::{self, LuaValue as V},
wire::escape,
};
use anyhow::{Context, Result, ensure};
use std::{
collections::BTreeMap,
path::Path,
time::{Duration, Instant},
};
fn text(v: Option<&V>) -> Option<String> {
match v {
Some(V::String(b)) => String::from_utf8(b.clone()).ok(),
_ => None,
}
}
fn s(v: &str) -> V {
V::String(v.as_bytes().to_vec())
}
// Prosody get_child_text() selects direct children in the stanza's namespace
// and rejects elements containing child tags, rather than flattening extensions.
fn stanza_child_text(stanza: roxmltree::Node<'_, '_>, name: &str) -> Option<String> {
let child = stanza.children().find(|n| {
n.is_element()
&& n.tag_name().name() == name
&& n.tag_name().namespace() == stanza.tag_name().namespace()
})?;
if child.children().any(|n| n.is_element()) {
return None;
}
Some(
child
.children()
.filter(|n| n.is_text())
.filter_map(|n| n.text())
.collect(),
)
}
#[derive(Default)]
pub struct Push {
pending: BTreeMap<String, (String, String, String, Instant)>,
errors: BTreeMap<(String, String), u64>,
removed: Vec<(String, String)>,
}
impl Push {
/// Registration removals which must be reflected in live SM session watchdogs.
pub fn take_removed(&mut self) -> Vec<(String, String)> {
std::mem::take(&mut self.removed)
}
#[allow(clippy::too_many_arguments)]
pub fn notify_queue(
&mut self,
root: &Path,
domain: &str,
user: &str,
messages: &[String],
config: &Config,
identifier: &str,
) -> Result<QueueNotification> {
let mut result = QueueNotification::default();
let mut sent = [false; 2];
let equal = config.option(domain, "push_notification_important_body")
== Some(&serde_json::Value::Bool(false));
for xml in messages {
let Ok(doc) = roxmltree::Document::parse(xml) else {
continue;
};
let stanza = doc.root_element();
let important = is_important(stanza);
let index = usize::from(important && !equal);
if sent[index] {
continue;
}
let deliveries =
self.notify_for_identifier(root, domain, user, stanza, config, Some(identifier))?;
if !deliveries.is_empty() {
sent[index] = true;
result.reset_watchdog |= important || equal;
result.deliveries.extend(deliveries);
}
if sent[0] && sent[1] {
break;
}
}
Ok(result)
}
pub fn handle(
&mut self,
root: &Path,
domain: &str,
jid: &str,
iq: roxmltree::Node<'_, '_>,
max_devices: usize,
) -> Result<Option<ModuleResult>> {
self.handle_with_client(root, domain, jid, iq, max_devices, None)
}
#[allow(clippy::too_many_arguments)]
pub fn handle_with_client(
&mut self,
root: &Path,
domain: &str,
jid: &str,
iq: roxmltree::Node<'_, '_>,
max_devices: usize,
client_id: Option<&str>,
) -> Result<Option<ModuleResult>> {
let Some(q) = iq.first_element_child() else {
return Ok(None);
};
if q.tag_name().namespace() != Some("urn:xmpp:push:0") {
return Ok(None);
}
let owner = protocol::bare(jid);
let (user, h) = owner.split_once('@').context("invalid account")?;
if h != domain
|| iq.attribute("type") != Some("set")
|| iq
.attribute("to")
.is_some_and(|to| to != domain && protocol::bare(to) != owner)
{
return Ok(Some(ModuleResult::reply(protocol::iq_error(
iq,
"forbidden",
))));
}
let Some(service) = q.attribute("jid").filter(|v| !v.is_empty() && *v != jid) else {
return Ok(Some(ModuleResult::reply(protocol::iq_error(
iq,
"bad-request",
))));
};
let node = q.attribute("node");
if !matches!(q.tag_name().name(), "enable" | "disable") {
return Ok(Some(ModuleResult::reply(protocol::iq_error(
iq,
"bad-request",
))));
}
storage::update(root, domain, Some(user), "cloud_notify", |table| {
if q.tag_name().name() == "enable" {
let key = format!("{service}<{}", node.unwrap_or(""));
let mut record = V::default();
record.set("jid", s(service));
if let Some(node) = node {
record.set("node", s(node));
}
if let Some(p) = q.attribute("include_payload") {
record.set("include_payload", s(p));
}
record.set("timestamp", V::Integer(chrono::Utc::now().timestamp()));
if let Some(client_id) = client_id {
record.set("client_id", s(client_id));
} else {
record.set(
"resource",
s(jid.split_once('/').map(|(_, r)| r).unwrap_or("")),
);
}
if let Some(language) =
iq.attribute(("http://www.w3.org/XML/1998/namespace", "lang"))
{
record.set("language", s(language));
}
if let Some(options) = q
.children()
.find(|n| n.is_element() && n.tag_name().namespace() == Some("jabber:x:data"))
{
record.set("options", storage::stanza_value(options));
}
table.set(&key, record);
if let V::Table(entries) = table {
while entries.len() > max_devices.max(1) {
let i = entries
.iter()
.enumerate()
.min_by_key(|(_, (_, v))| match v.get("timestamp") {
Some(V::Integer(n)) => *n,
_ => 0,
})
.map(|(i, _)| i)
.unwrap();
entries.remove(i);
}
}
} else if let V::Table(entries) = table {
entries.retain(|(_, v)| {
!(text(v.get("jid")).as_deref() == Some(service)
&& node.is_none_or(|n| text(v.get("node")).as_deref() == Some(n)))
});
}
Ok(())
})?;
// A fresh registration must not inherit failures or responses for its previous incarnation.
self.pending.retain(|_, (pending_user, key, _, _)| {
!(pending_user == user
&& (key == &format!("{service}<{}", node.unwrap_or(""))
|| (q.tag_name().name() == "disable"
&& node.is_none()
&& key.split_once('<').is_some_and(|(s, _)| s == service))))
});
self.errors.retain(|(pending_user, key), _| {
!(pending_user == user
&& (key == &format!("{service}<{}", node.unwrap_or(""))
|| (q.tag_name().name() == "disable"
&& node.is_none()
&& key.split_once('<').is_some_and(|(s, _)| s == service))))
});
Ok(Some(ModuleResult::reply(protocol::iq_result(iq, ""))))
}
pub fn notify(
&mut self,
root: &Path,
domain: &str,
user: &str,
message: roxmltree::Node<'_, '_>,
config: &Config,
) -> Result<Vec<(String, String)>> {
self.notify_for_identifier(root, domain, user, message, config, None)
}
#[allow(clippy::too_many_arguments)]
pub fn notify_for_identifier(
&mut self,
root: &Path,
domain: &str,
user: &str,
message: roxmltree::Node<'_, '_>,
config: &Config,
only: Option<&str>,
) -> Result<Vec<(String, String)>> {
self.pending
.retain(|_, (_, _, _, at)| at.elapsed() < Duration::from_secs(120));
ensure!(self.pending.len() < 8192, "too many pending pushes");
let table = storage::load(root, domain, Some(user), "cloud_notify")?.unwrap_or_default();
let V::Table(entries) = table else {
return Ok(vec![]);
};
let body = stanza_child_text(message, "body");
let include_body = config
.option(domain, "push_notification_with_body")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let include_sender = config
.option(domain, "push_notification_with_sender")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mut outputs = vec![];
for (key, record) in entries {
let Some(service) = text(record.get("jid")) else {
continue;
};
let node = text(record.get("node"));
let Some(identifier) = text(Some(&key)) else {
continue;
};
if only.is_some_and(|only| only != identifier) {
continue;
}
let id = uuid::Uuid::new_v4().to_string();
let mut form="<x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE' type='hidden'><value>urn:xmpp:push:summary</value></field><field var='message-count'><value>1</value></field>".to_owned();
if include_sender {
form.push_str(&format!(
"<field var='last-message-sender'><value>{}</value></field>",
escape(message.attribute("from").unwrap_or(""))
));
}
let summary = if include_body {
body.as_deref()
} else if is_important(message) {
match config.option(domain, "push_notification_important_body") {
Some(serde_json::Value::Bool(false)) => None,
Some(serde_json::Value::String(s)) => Some(s.as_str()),
_ => Some("New Message!"),
}
} else {
None
};
if let Some(body) = summary {
form.push_str(&format!(
"<field var='last-message-body'><value>{}</value></field>",
escape(body)
));
}
form.push_str("</x>");
let node_attr = node
.map(|n| format!(" node='{}'", escape(&n)))
.unwrap_or_default();
let mut payload = format!(
"<iq xmlns='jabber:client' type='set' from='{}' to='{}' id='{id}'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish{node_attr}><item><notification xmlns='urn:xmpp:push:0'>{form}</notification></item></publish>",
escape(domain),
escape(&service)
);
if let Some(options) = record.get("options") {
payload.push_str(&format!(
"<publish-options>{}</publish-options>",
storage::stanza_xml(options)?
));
}
payload.push_str("</pubsub></iq>");
self.pending.insert(
id,
(user.into(), identifier, service.clone(), Instant::now()),
);
outputs.push((service, payload));
}
Ok(outputs)
}
pub fn result(
&mut self,
root: &Path,
domain: &str,
iq: roxmltree::Node<'_, '_>,
max_errors: u64,
) -> Result<bool> {
let Some((user, key, service, sent_at)) =
self.pending.get(iq.attribute("id").unwrap_or("")).cloned()
else {
return Ok(false);
};
if sent_at.elapsed() >= Duration::from_secs(120) {
self.pending.remove(iq.attribute("id").unwrap_or(""));
return Ok(false);
}
if iq.attribute("from") != Some(service.as_str()) {
return Ok(false);
}
if !matches!(iq.attribute("type"), Some("result" | "error")) {
return Ok(false);
}
self.pending.remove(iq.attribute("id").unwrap_or(""));
let error_key = (user.clone(), key.clone());
if iq.attribute("type") == Some("result") {
self.errors.remove(&error_key);
return Ok(true);
}
if iq.attribute("type") == Some("error")
&& !iq.children().any(|n| {
n.is_element()
&& n.tag_name().name() == "error"
&& n.attribute("type") == Some("wait")
})
{
let count = self.errors.entry(error_key.clone()).or_default();
*count += 1;
if *count >= max_errors {
storage::update(root, domain, Some(&user), "cloud_notify", |table| {
table.set(&key, V::Nil);
Ok(())
})?;
self.errors.remove(&error_key);
self.removed.push((user.clone(), key.clone()));
}
}
Ok(true)
}
}
/// Locate the most recent registration for a stable client identity or a legacy resource.
/// A resource match never overrides a stored stable identity belonging to another device.
pub fn client_device(
root: &Path,
domain: &str,
user: &str,
client_id: Option<&str>,
resource: &str,
) -> Result<Option<String>> {
let table = storage::load(root, domain, Some(user), "cloud_notify")?.unwrap_or_default();
let V::Table(entries) = table else {
return Ok(None);
};
Ok(entries
.into_iter()
.filter_map(|(key, record)| {
let matches = match text(record.get("client_id")) {
Some(stored) => client_id == Some(stored.as_str()),
None => text(record.get("resource")).as_deref() == Some(resource),
};
if !matches || text(record.get("jid")).is_none() {
return None;
}
let timestamp = match record.get("timestamp") {
Some(V::Integer(n)) => *n,
_ => 0,
};
text(Some(&key)).map(|key| (timestamp, key))
})
.max()
.map(|(_, key)| key))
}
#[derive(Default, Debug)]
pub struct QueueNotification {
pub deliveries: Vec<(String, String)>,
pub reset_watchdog: bool,
}
/// Prosody cloud_notify importance classification, including incoming carbon wrappers.
pub fn is_important(message: roxmltree::Node<'_, '_>) -> bool {
if message.tag_name().name() != "message" {
return false;
}
let mut inner = message;
if let Some(carbon) = message.children().find(|n| {
n.is_element()
&& n.tag_name().namespace() == Some("urn:xmpp:carbons:2")
&& matches!(n.tag_name().name(), "sent" | "received")
}) {
if carbon.tag_name().name() == "sent" {
return false;
}
if let Some(stanza) = carbon
.children()
.find(|n| n.has_tag_name(("urn:xmpp:forward:0", "forwarded")))
.and_then(|f| {
f.children()
.find(|n| n.has_tag_name(("jabber:client", "message")))
})
{
inner = stanza;
}
}
if inner.attribute("type") == Some("headline") {
return false;
}
if inner.children().any(|n| {
matches!(
(n.tag_name().namespace(), n.tag_name().name()),
(
Some("eu.siacs.conversations.axolotl" | "urn:xmpp:omemo:0" | "urn:xmpp:omemo:2"),
"encrypted"
) | (Some("jabber:x:encrypted"), "x")
| (Some("urn:xmpp:openpgp:0"), "openpgp")
| (Some("urn:xmpp:jingle-message:0"), "propose")
)
}) {
return true;
}
if inner.attribute("type") == Some("groupchat") && stanza_child_text(inner, "subject").is_some()
{
return false;
}
stanza_child_text(inner, "body").is_some_and(|body| !body.is_empty())
}
/// Native first-important-push rule; caller invokes only after a push was emitted.
pub fn resets_watchdog(message: roxmltree::Node<'_, '_>, config: &Config, domain: &str) -> bool {
is_important(message)
|| config.option(domain, "push_notification_important_body")
== Some(&serde_json::Value::Bool(false))
}
+467
View File
@@ -0,0 +1,467 @@
//! Transport-independent SCRAM exchange. Inputs/outputs are decoded SASL payloads.
//! The transport must enforce TLS and advertise only mechanisms enabled for the account store.
use crate::auth::ScramCredentials;
use anyhow::{Result, bail, ensure};
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use hmac::{Hmac, Mac};
use sha1::Sha1;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
#[derive(Clone, Debug)]
pub struct ChannelBinding {
pub name: String,
pub data: Vec<u8>,
}
#[derive(Clone, Debug, Default)]
pub struct Options {
pub channel_binding: Option<ChannelBinding>,
/// Exact advertised mechanism list. None disables Prosody's SSDP extension.
pub ssdp_mechanisms: Option<Vec<String>>,
/// Exact supported CB types. None means the profile has no CB table.
pub ssdp_channel_bindings: Option<Vec<String>>,
}
#[derive(Clone, Copy)]
enum Hash {
Sha1,
Sha256,
}
impl Hash {
fn digest(self, bytes: &[u8]) -> Vec<u8> {
match self {
Self::Sha1 => Sha1::digest(bytes).to_vec(),
Self::Sha256 => Sha256::digest(bytes).to_vec(),
}
}
fn mac(self, key: &[u8], bytes: &[u8]) -> Vec<u8> {
macro_rules! mac {
($t:ty) => {{
let mut m = Hmac::<$t>::new_from_slice(key).expect("HMAC accepts any key length");
m.update(bytes);
m.finalize().into_bytes().to_vec()
}};
}
match self {
Self::Sha1 => mac!(Sha1),
Self::Sha256 => mac!(Sha256),
}
}
fn len(self) -> usize {
match self {
Self::Sha1 => 20,
Self::Sha256 => 32,
}
}
}
pub struct Scram {
username: String,
hash: Hash,
credentials: ScramCredentials,
first_bare: String,
server_first: String,
nonce: String,
binding: Vec<u8>,
eligible: bool,
}
#[derive(Debug)]
pub struct ScramSuccess {
pub username: String,
pub server_final: String,
}
fn unescape_name(value: &str) -> Result<String> {
let mut out = String::new();
let mut chars = value.chars();
while let Some(c) = chars.next() {
if c != '=' {
out.push(c);
continue;
}
match (chars.next(), chars.next()) {
(Some('2'), Some('C')) => out.push(','),
(Some('3'), Some('D')) => out.push('='),
_ => bail!("malformed-request"),
}
}
Ok(out)
}
fn attributes(input: &str) -> Result<Vec<(&str, &str)>> {
let mut out = Vec::new();
for part in input.split(',') {
let (key, value) = part
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("malformed-request"))?;
ensure!(
key.len() == 1
&& key.as_bytes()[0].is_ascii_alphabetic()
&& !out.iter().any(|(k, _)| *k == key),
"malformed-request"
);
ensure!(key != "m", "extensions-not-supported");
out.push((key, value));
}
Ok(out)
}
impl Scram {
/// `lookup` receives the prepared username and the selected password hash name.
pub fn start<F>(
mechanism: &str,
initial: &[u8],
domain: &str,
lookup: F,
options: Options,
) -> Result<(Self, String)>
where
F: FnOnce(&str, &str) -> Result<Option<ScramCredentials>>,
{
Self::start_nonce(
mechanism,
initial,
domain,
lookup,
options,
&uuid::Uuid::new_v4().simple().to_string(),
)
}
fn start_nonce<F>(
mechanism: &str,
initial: &[u8],
domain: &str,
lookup: F,
options: Options,
server_nonce: &str,
) -> Result<(Self, String)>
where
F: FnOnce(&str, &str) -> Result<Option<ScramCredentials>>,
{
ensure!(initial.len() <= 8192, "malformed-request");
let (hash, hash_name, plus) = match mechanism {
"SCRAM-SHA-1" => (Hash::Sha1, "SHA-1", false),
"SCRAM-SHA-256" => (Hash::Sha256, "SHA-256", false),
"SCRAM-SHA-1-PLUS" => (Hash::Sha1, "SHA-1", true),
"SCRAM-SHA-256-PLUS" => (Hash::Sha256, "SHA-256", true),
_ => bail!("invalid-mechanism"),
};
let input = std::str::from_utf8(initial)?;
let mut parts = input.splitn(3, ',');
let flag = parts.next().unwrap_or("");
let authz = parts
.next()
.ok_or_else(|| anyhow::anyhow!("malformed-request"))?;
let bare = parts
.next()
.ok_or_else(|| anyhow::anyhow!("malformed-request"))?;
let gs2 = format!("{flag},{authz},");
let mut binding = gs2.as_bytes().to_vec();
if plus {
let cb = options
.channel_binding
.as_ref()
.ok_or_else(|| anyhow::anyhow!("channel-binding-not-supported"))?;
ensure!(
flag == format!("p={}", cb.name) && !cb.data.is_empty(),
"unsupported-channel-binding-type"
);
binding.extend_from_slice(&cb.data);
} else {
ensure!(flag == "n" || flag == "y", "malformed-request");
ensure!(
flag != "y" || options.channel_binding.is_none(),
"server-does-support-channel-binding"
);
}
let attrs = attributes(bare)?;
ensure!(
attrs.len() >= 2 && attrs[0].0 == "n" && attrs[1].0 == "r",
"malformed-request"
);
let decoded = unescape_name(attrs[0].1)?;
let prepared = stringprep::saslprep(&decoded)
.map_err(|_| anyhow::anyhow!("invalid-username-encoding"))?;
let username = stringprep::nodeprep(&prepared)
.map_err(|_| anyhow::anyhow!("invalid-username-encoding"))?
.into_owned();
ensure!(
!username.is_empty() && username.len() <= 1023,
"invalid-username-encoding"
);
if !authz.is_empty() {
let az = authz
.strip_prefix("a=")
.ok_or_else(|| anyhow::anyhow!("malformed-request"))?;
let az = unescape_name(az)?;
ensure!(az == format!("{username}@{domain}"), "invalid-authzid");
}
let nonce = attrs[1].1;
ensure!(
!nonce.is_empty()
&& nonce.len() <= 1024
&& nonce
.bytes()
.all(|b| (0x21..=0x7e).contains(&b) && b != b','),
"malformed-request"
);
let record = lookup(&username, hash_name)?;
let eligible = record.is_some();
// Return an ordinary challenge for unknown/disabled accounts. Never accept its proof.
let credentials = record.unwrap_or_else(|| ScramCredentials {
salt: uuid::Uuid::new_v4().to_string().into_bytes(),
iterations: 10000,
stored_key: hash.digest(uuid::Uuid::new_v4().as_bytes()),
server_key: hash.digest(uuid::Uuid::new_v4().as_bytes()),
});
ensure!(
credentials.stored_key.len() == hash.len()
&& credentials.server_key.len() == hash.len(),
"invalid-credentials"
);
ensure!(
credentials.iterations > 0
&& credentials.iterations <= 1_000_000
&& !credentials.salt.is_empty(),
"invalid-credentials"
);
let nonce = format!("{nonce}{server_nonce}");
let mut challenge = format!(
"r={nonce},s={},i={}",
B64.encode(&credentials.salt),
credentials.iterations
);
if let Some(mut mechs) = options.ssdp_mechanisms {
mechs.sort();
mechs.dedup();
let mut text = mechs.join("\u{1e}");
if let Some(mut cb) = options.ssdp_channel_bindings {
cb.sort();
cb.dedup();
text.push('\u{1f}');
text.push_str(&cb.join("\u{1e}"));
}
challenge.push_str(&format!(",h={}", B64.encode(hash.digest(text.as_bytes()))));
}
Ok((
Self {
username,
hash,
credentials,
first_bare: bare.into(),
server_first: challenge.clone(),
nonce,
binding,
eligible,
},
challenge,
))
}
/// Consumes the exchange so a proof can never be submitted twice.
pub fn finish(self, response: &[u8]) -> Result<ScramSuccess> {
ensure!(response.len() <= 8192, "malformed-request");
let response = std::str::from_utf8(response)?;
let attrs = attributes(response)?;
ensure!(
attrs.len() >= 3
&& attrs[0].0 == "c"
&& attrs[1].0 == "r"
&& attrs.last().unwrap().0 == "p",
"malformed-request"
);
let binding = B64.decode(attrs[0].1)?;
ensure!(
bool::from(binding.ct_eq(&self.binding)),
"channel-bindings-dont-match"
);
ensure!(attrs[1].1 == self.nonce, "nonce-mismatch");
let proof = B64.decode(attrs.last().unwrap().1)?;
ensure!(proof.len() == self.hash.len(), "malformed-request");
let without = response
.rsplit_once(",p=")
.ok_or_else(|| anyhow::anyhow!("malformed-request"))?
.0;
let auth_message = format!("{},{},{}", self.first_bare, self.server_first, without);
let signature = self
.hash
.mac(&self.credentials.stored_key, auth_message.as_bytes());
let client_key: Vec<u8> = proof.iter().zip(signature).map(|(a, b)| a ^ b).collect();
let recovered = self.hash.digest(&client_key);
ensure!(
bool::from(recovered.ct_eq(&self.credentials.stored_key)) && self.eligible,
"invalid-client-proof"
);
let server_sig = self
.hash
.mac(&self.credentials.server_key, auth_message.as_bytes());
Ok(ScramSuccess {
username: self.username,
server_final: format!("v={}", B64.encode(server_sig)),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn credentials(hash: Hash, password: &str, salt: &[u8], iterations: u32) -> ScramCredentials {
let mut salted = vec![0; hash.len()];
match hash {
Hash::Sha1 => {
pbkdf2::pbkdf2_hmac::<Sha1>(password.as_bytes(), salt, iterations, &mut salted)
}
Hash::Sha256 => {
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, &mut salted)
}
}
ScramCredentials {
salt: salt.to_vec(),
iterations,
stored_key: hash.digest(&hash.mac(&salted, b"Client Key")),
server_key: hash.mac(&salted, b"Server Key"),
}
}
#[test]
fn rfc5802_sha1_vector() {
let salt = B64.decode("QSXCR+Q6sek8bf92").unwrap();
let c = credentials(Hash::Sha1, "pencil", &salt, 4096);
let (exchange, challenge) = Scram::start_nonce(
"SCRAM-SHA-1",
b"n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL",
"example.com",
|_, _| Ok(Some(c)),
Options::default(),
"3rfcNHYJY1ZVvWVs7j",
)
.unwrap();
assert_eq!(
challenge,
"r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096"
);
let success=exchange.finish(b"c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=").unwrap();
assert_eq!(success.username, "user");
assert_eq!(success.server_final, "v=rmF9pqV8S7suAoZWja4dJRkFsKQ=");
}
#[test]
fn sha256_independent_python_vector() {
// Generated independently with Python hashlib.pbkdf2_hmac/hmac, password pencil.
let c = credentials(Hash::Sha256, "pencil", b"test salt", 4096);
let (exchange, challenge) = Scram::start_nonce(
"SCRAM-SHA-256",
b"n,,n=user,r=clientnonce",
"example.com",
|_, _| Ok(Some(c)),
Options::default(),
"servernonce",
)
.unwrap();
assert_eq!(challenge, "r=clientnonceservernonce,s=dGVzdCBzYWx0,i=4096");
let success = exchange
.finish(
b"c=biws,r=clientnonceservernonce,p=azcZj1W9+zOyKcsNxPTVP8SgNvdyoJICB///1hzW17Y=",
)
.unwrap();
assert_eq!(
success.server_final,
"v=sfQ9BDV6Z/TTbpW9vXGpn0fUZ0N9+zgUnhGHEylidY4="
);
}
#[test]
fn reject_malformed_and_downgrade() {
for initial in [
"n,,m=required,n=user,r=nonce",
"n,,n=user,n=other,r=nonce",
"n,,n=user,r=",
"n,a=other@example.com,n=user,r=nonce",
"p=tls-exporter,,n=user,r=nonce",
"n,,n=user=ZZ,r=nonce",
] {
assert!(
Scram::start(
"SCRAM-SHA-256",
initial.as_bytes(),
"example.com",
|_, _| Ok(None),
Options::default()
)
.is_err(),
"{initial}"
);
}
assert!(
Scram::start(
"SCRAM-SHA-256",
b"y,,n=user,r=nonce",
"example.com",
|_, _| Ok(None),
Options {
channel_binding: Some(ChannelBinding {
name: "tls-exporter".into(),
data: vec![1; 32]
}),
..Options::default()
}
)
.is_err()
);
}
#[test]
fn wrong_proof_nonce_binding_and_duplicates() {
for response in [
"c=biws,r=nonceserver,p=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"c=biws,r=wrong,p=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"c=eSws,r=nonceserver,p=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"c=biws,r=nonceserver,r=nonceserver,p=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
] {
let c = credentials(Hash::Sha256, "password", b"salt", 4096);
let (state, _) = Scram::start_nonce(
"SCRAM-SHA-256",
b"n,,n=user,r=nonce",
"example.com",
|_, _| Ok(Some(c)),
Options::default(),
"server",
)
.unwrap();
assert!(state.finish(response.as_bytes()).is_err());
}
}
#[test]
fn plus_channel_binding_and_ssdp_are_authenticated() {
let options = Options {
channel_binding: Some(ChannelBinding {
name: "tls-exporter".into(),
data: vec![7; 32],
}),
ssdp_mechanisms: Some(vec!["PLAIN".into(), "SCRAM-SHA-256-PLUS".into()]),
ssdp_channel_bindings: Some(vec!["tls-exporter".into()]),
};
let c = credentials(Hash::Sha256, "pencil", b"salt", 4096);
let (state, challenge) = Scram::start_nonce(
"SCRAM-SHA-256-PLUS",
b"p=tls-exporter,,n=user,r=nonce",
"example.com",
|_, _| Ok(Some(c)),
options,
"server",
)
.unwrap();
let expected_hash = B64.encode(Sha256::digest(
b"PLAIN\x1eSCRAM-SHA-256-PLUS\x1ftls-exporter",
));
assert!(challenge.ends_with(&format!(",h={expected_hash}")));
let mut binding = b"p=tls-exporter,,".to_vec();
binding.extend_from_slice(&[7; 32]);
let final_without = format!("c={},r=nonceserver", B64.encode(binding));
let auth = format!("n=user,r=nonce,{challenge},{final_without}");
let mut salted = [0; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(b"pencil", b"salt", 4096, &mut salted);
let client_key = Hash::Sha256.mac(&salted, b"Client Key");
let signature = Hash::Sha256.mac(&Sha256::digest(&client_key), auth.as_bytes());
let proof: Vec<u8> = client_key
.iter()
.zip(signature)
.map(|(a, b)| a ^ b)
.collect();
assert!(
state
.finish(format!("{final_without},p={}", B64.encode(proof)).as_bytes())
.is_ok()
);
}
}
+3787
View File
File diff suppressed because it is too large. Load diff
+478
View File
@@ -0,0 +1,478 @@
use crate::protocol::bare;
use anyhow::{Result, ensure};
use std::{
collections::{BTreeMap, VecDeque},
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tokio::sync::mpsc;
#[derive(Clone, Debug)]
pub struct Session {
pub jid: String,
pub owner: String,
pub client_id: Option<String>,
pub push_identifier: Option<String>,
pub first_push_sent: bool,
pub connection: String,
pub sender: Option<mpsc::Sender<String>>,
pub available: bool,
pub priority: i8,
pub presence: Option<String>,
pub carbons: bool,
pub csi: crate::csi::Buffer,
pub roster_interested: bool,
pub sm: bool,
pub resume_id: Option<String>,
pub handled: u32,
pub sent: u32,
pub acked: u32,
pub ack_limit: usize,
pub ack_requested: bool,
pub pending: VecDeque<String>,
pub expires: Option<Instant>,
pub timeout: Duration,
pub closed: bool,
pub close_condition: Option<String>,
pub cancel: Arc<tokio::sync::Notify>,
}
impl Session {
pub fn first_important_push(&mut self, normal_timeout: Duration) {
if self.sender.is_none() && self.expires.is_some() && !self.first_push_sent {
self.first_push_sent = true;
self.expires = Some(Instant::now() + normal_timeout);
}
}
pub fn transmit(&mut self, xml: String) -> Result<()> {
ensure!(!self.closed, "session closed");
if self.sm {
ensure!(self.pending.len() < 4096, "stream management queue limit");
}
if let Some(tx) = &self.sender {
let permit = tx
.try_reserve()
.map_err(|_| anyhow::anyhow!("recipient queue unavailable"))?;
if self.sm {
self.sent = self.sent.wrapping_add(1);
self.pending.push_back(xml.clone());
}
permit.send(xml);
} else {
ensure!(
self.sm && self.resume_id.is_some(),
"recipient disconnected"
);
self.sent = self.sent.wrapping_add(1);
self.pending.push_back(xml);
}
Ok(())
}
pub fn request_ack(&mut self) -> bool {
if self.sm && self.pending.len() >= self.ack_limit.max(1) && !self.ack_requested {
self.ack_requested = true;
true
} else {
false
}
}
pub fn acknowledge(&mut self, h: u32) -> Result<()> {
ensure!(!self.closed, "session closed");
let n = h.wrapping_sub(self.acked) as usize;
ensure!(n <= self.pending.len(), "invalid stream acknowledgement");
self.pending.drain(..n);
self.acked = h;
self.ack_requested = false;
Ok(())
}
pub fn enable(&mut self, resume: bool) -> Result<String> {
ensure!(!self.closed, "session closed");
ensure!(!self.sm, "stream management already enabled");
self.sm = true;
if resume {
self.resume_id = Some(uuid::Uuid::new_v4().to_string());
}
Ok(match &self.resume_id {
Some(id) => format!(
"<enabled xmlns='urn:xmpp:sm:3' id='{id}' resume='true' max='{}'/>",
self.timeout.as_secs()
),
None => "<enabled xmlns='urn:xmpp:sm:3'/>".into(),
})
}
}
pub type SharedSession = Arc<Mutex<Session>>;
/// Prosody's conflict_resolve policy; unspecified values use kick_old.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConflictPolicy {
KickOld,
KickNew,
Increment,
Random,
}
impl ConflictPolicy {
pub fn from_config(value: Option<&str>) -> Self {
match value {
Some("kick_new") => Self::KickNew,
Some("increment") => Self::Increment,
Some("random") => Self::Random,
_ => Self::KickOld,
}
}
}
#[derive(Clone, Default)]
pub struct Hub {
sessions: Arc<Mutex<BTreeMap<String, SharedSession>>>,
}
impl Hub {
pub fn bind(
&self,
jid: &str,
connection: &str,
sender: mpsc::Sender<String>,
timeout: Duration,
) -> Result<SharedSession> {
self.bind_resolving(jid, connection, sender, timeout, ConflictPolicy::KickNew)
.map(|(session, _)| session)
}
/// Choose and reserve the resource under the same lock as replacement.
pub fn bind_resolving(
&self,
jid: &str,
connection: &str,
sender: mpsc::Sender<String>,
timeout: Duration,
policy: ConflictPolicy,
) -> Result<(SharedSession, Option<SharedSession>)> {
let mut map = self.sessions.lock().unwrap();
let mut jid = jid.to_owned();
let mut replaced = None;
if map.contains_key(&jid) {
match policy {
ConflictPolicy::KickNew => anyhow::bail!("resource conflict"),
ConflictPolicy::KickOld => {
let old = map.remove(&jid).unwrap();
{
let mut state = old.lock().unwrap();
state.closed = true;
state.close_condition = Some("conflict".into());
state.sender = None;
state.resume_id = None;
state.expires = None;
state.cancel.notify_one();
}
replaced = Some(old);
}
ConflictPolicy::Increment => {
let base = jid.clone();
let mut count = 1;
while map.contains_key(&format!("{base}#{count}")) {
count += 1;
}
jid = format!("{base}#{count}");
}
ConflictPolicy::Random => {
let owner = bare(&jid).to_owned();
loop {
jid = format!("{owner}/{}", uuid::Uuid::new_v4());
if !map.contains_key(&jid) {
break;
}
}
}
}
}
let s = Arc::new(Mutex::new(Session {
jid: jid.clone(),
owner: bare(&jid).into(),
client_id: None,
push_identifier: None,
first_push_sent: false,
connection: connection.into(),
sender: Some(sender),
available: false,
priority: 0,
presence: None,
carbons: false,
csi: Default::default(),
roster_interested: false,
sm: false,
resume_id: None,
handled: 0,
sent: 0,
acked: 0,
ack_limit: 100,
ack_requested: false,
pending: VecDeque::new(),
expires: None,
timeout,
closed: false,
close_condition: None,
cancel: Default::default(),
}));
map.insert(jid, s.clone());
Ok((s, replaced))
}
pub fn kick_owner(&self, owner: &str) {
self.sessions.lock().unwrap().retain(|_, s| {
let mut s = s.lock().unwrap();
if s.owner == owner {
s.sender = None;
s.closed = true;
s.cancel.notify_one();
false
} else {
true
}
});
}
pub fn get(&self, jid: &str) -> Option<SharedSession> {
self.sessions.lock().unwrap().get(jid).cloned()
}
pub fn all(&self) -> Vec<SharedSession> {
self.sessions.lock().unwrap().values().cloned().collect()
}
pub fn deliver(&self, to: &str, xml: &str) -> usize {
let roster_push = roxmltree::Document::parse(xml).ok().is_some_and(|d| {
d.root_element()
.attribute("id")
.is_some_and(|id| id.starts_with("roster-"))
});
self.all()
.into_iter()
.filter(|s| {
let mut s = s.lock().unwrap();
let matches = if to.contains('/') {
s.jid == to
} else {
s.owner == to
};
matches && (!roster_push || s.roster_interested) && s.transmit(xml.into()).is_ok()
})
.count()
}
pub fn message_targets(&self, to: &str) -> Vec<SharedSession> {
self.message_targets_for_type(to, "normal")
}
/// Native mod_message first tries the exact full JID, then applies the
/// message-type-specific bare-JID policy when that resource is absent.
pub fn message_targets_for_type(&self, to: &str, kind: &str) -> Vec<SharedSession> {
if to.contains('/') {
if let Some(session) = self.get(to) {
return vec![session];
}
if matches!(kind, "headline" | "error" | "groupchat") {
return vec![];
}
} else if matches!(kind, "error" | "groupchat") {
return vec![];
}
let bare = to.split_once('/').map(|(bare, _)| bare).unwrap_or(to);
let mut max = None;
let mut out = vec![];
for s in self.all() {
let p = {
let state = s.lock().unwrap();
if state.owner != bare || !state.available || state.priority < 0 {
continue;
}
state.priority
};
if kind == "headline" {
out.push(s);
continue;
}
if max.is_none_or(|v| p > v) {
max = Some(p);
out.clear();
}
if max == Some(p) {
out.push(s);
}
}
out
}
pub fn disconnect(&self, connection: &str, graceful: bool) -> Vec<String> {
let mut removed = vec![];
self.sessions.lock().unwrap().retain(|jid, s| {
let mut s = s.lock().unwrap();
if s.connection != connection {
return true;
}
if !graceful && s.sm && s.resume_id.is_some() {
s.csi.reset();
s.sender = None;
s.expires = Some(Instant::now() + s.timeout);
true
} else {
removed.push(jid.clone());
false
}
});
removed
}
pub fn expired(&self) -> Vec<String> {
let mut out = vec![];
self.sessions.lock().unwrap().retain(|jid, s| {
if s.lock()
.unwrap()
.expires
.is_some_and(|t| Instant::now() >= t)
{
out.push(jid.clone());
false
} else {
true
}
});
out
}
pub fn resume(
&self,
owner: &str,
id: &str,
h: u32,
connection: &str,
sender: mpsc::Sender<String>,
) -> Result<(SharedSession, Vec<String>)> {
self.expired();
let mut map = self.sessions.lock().unwrap();
let candidate = map
.values()
.find(|session| {
let state = session.lock().unwrap();
!state.closed && state.owner == owner && state.resume_id.as_deref() == Some(id)
})
.cloned();
if let Some(previous) = candidate {
let mut old = previous.lock().unwrap();
// Validate before changing the old transport or any session counters.
let count = h.wrapping_sub(old.acked) as usize;
ensure!(count <= old.pending.len(), "invalid stream acknowledgement");
// Keep a retired transport's in-flight operations isolated from the
// resumed queue, acknowledgements and CSI state.
let mut state = old.clone();
state.acknowledge(h)?;
state.cancel = Default::default();
state.csi.reset();
state.first_push_sent = false;
state.sender = Some(sender);
state.connection = connection.into();
state.expires = None;
let mut replay = vec![format!(
"<resumed xmlns='urn:xmpp:sm:3' previd='{id}' h='{}'/>",
state.handled
)];
replay.extend(state.pending.iter().cloned());
old.closed = true;
old.close_condition = Some("conflict".into());
old.sender = None;
old.resume_id = None;
old.expires = None;
old.pending.clear();
old.csi.reset();
old.cancel.notify_one();
let jid = state.jid.clone();
let resumed = Arc::new(Mutex::new(state));
map.insert(jid, resumed.clone());
return Ok((resumed, replay));
}
anyhow::bail!("resume session not found")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resume_replays_only_unacked_and_checks_owner() {
let h = Hub::default();
let (tx, _rx) = mpsc::channel(8);
let s = h.bind("a@x/r", "c1", tx, Duration::from_secs(30)).unwrap();
let id;
{
let mut s = s.lock().unwrap();
s.client_id = Some("stable-device".into());
s.push_identifier = Some("push.test<node".into());
s.enable(true).unwrap();
id = s.resume_id.clone().unwrap();
s.transmit("one".into()).unwrap();
s.transmit("two".into()).unwrap();
s.handled = 7;
s.csi.configure(256, Duration::from_secs(5), vec![]);
s.csi.set_inactive(true);
assert!(
s.csi
.output("<presence/>".into(), "a@x", Instant::now())
.is_empty()
);
}
h.disconnect("c1", false);
let (tx, _rx) = mpsc::channel(8);
assert!(h.resume("b@x", &id, 1, "c2", tx.clone()).is_err());
let (s, replay) = h.resume("a@x", &id, 1, "c2", tx).unwrap();
assert_eq!(
s.lock().unwrap().client_id.as_deref(),
Some("stable-device")
);
assert_eq!(
s.lock().unwrap().push_identifier.as_deref(),
Some("push.test<node")
);
assert_eq!(
s.lock()
.unwrap()
.csi
.output("<presence/>".into(), "a@x", Instant::now())
.len(),
1
);
assert_eq!(replay.len(), 2);
assert_eq!(replay[1], "two");
assert!(replay[0].contains("h='7'"));
assert!(s.lock().unwrap().acknowledge(3).is_err());
}
#[test]
fn graceful_disconnect_is_not_resumable() {
let h = Hub::default();
let (tx, _rx) = mpsc::channel(8);
let s = h.bind("a@x/r", "c", tx, Duration::from_secs(30)).unwrap();
s.lock().unwrap().enable(true).unwrap();
assert_eq!(h.disconnect("c", true), vec!["a@x/r"]);
assert!(h.get("a@x/r").is_none());
}
#[test]
fn acknowledgement_wraps() {
let h = Hub::default();
let (tx, _rx) = mpsc::channel(8);
let s = h.bind("a@x/r", "c", tx, Duration::from_secs(30)).unwrap();
let mut s = s.lock().unwrap();
s.sm = true;
s.sent = u32::MAX;
s.acked = u32::MAX;
s.transmit("x".into()).unwrap();
s.acknowledge(0).unwrap();
assert!(s.pending.is_empty());
}
}
#[cfg(test)]
mod push_timeout_tests {
use super::*;
#[test]
fn first_important_push_shortens_extended_hibernation_once() {
let hub = Hub::default();
let (tx, _rx) = mpsc::channel(8);
let session = hub
.bind("a@x/r", "c", tx, Duration::from_secs(72 * 3600))
.unwrap();
session.lock().unwrap().enable(true).unwrap();
hub.disconnect("c", false);
let mut s = session.lock().unwrap();
let extended = s.expires.unwrap();
s.first_important_push(Duration::from_secs(300));
let first = s.expires.unwrap();
assert!(first < extended);
assert!(s.first_push_sent);
s.first_important_push(Duration::from_secs(900));
assert_eq!(s.expires.unwrap(), first);
}
}
+484
View File
@@ -0,0 +1,484 @@
//! Lossless, bounded Prosody internal key/value persistence.
use anyhow::{Result, bail, ensure};
use std::{
fs::{self, OpenOptions},
io::{Read, Write},
path::{Path, PathBuf},
sync::Mutex,
};
static TRANSACTION: Mutex<()> = Mutex::new(());
const LIMIT: usize = 16 * 1024 * 1024;
#[derive(Clone, Debug)]
pub enum LuaValue {
Nil,
Bool(bool),
Integer(i64),
Number(f64),
String(Vec<u8>),
Table(Vec<(LuaValue, LuaValue)>),
}
impl PartialEq for LuaValue {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Nil, Self::Nil) => true,
(Self::Bool(a), Self::Bool(b)) => a == b,
(Self::Integer(a), Self::Integer(b)) => a == b,
(Self::Number(a), Self::Number(b)) => a == b,
(Self::Integer(a), Self::Number(b)) | (Self::Number(b), Self::Integer(a)) => {
b.is_finite()
&& b.fract() == 0.0
&& *b >= i64::MIN as f64
&& *b < (i64::MAX as f64)
&& *a == *b as i64
}
(Self::String(a), Self::String(b)) => a == b,
(Self::Table(a), Self::Table(b)) => {
a.len() == b.len()
&& a.iter()
.all(|(k, v)| b.iter().any(|(k2, v2)| k == k2 && v == v2))
}
_ => false,
}
}
}
impl Default for LuaValue {
fn default() -> Self {
Self::Table(vec![])
}
}
impl From<&str> for LuaValue {
fn from(v: &str) -> Self {
Self::String(v.as_bytes().to_vec())
}
}
impl From<String> for LuaValue {
fn from(v: String) -> Self {
Self::String(v.into_bytes())
}
}
impl LuaValue {
pub fn table() -> Self {
Self::default()
}
pub fn get_key(&self, k: &Self) -> Option<&Self> {
if let Self::Table(t) = self {
t.iter().find(|(a, _)| a == k).map(|(_, v)| v)
} else {
None
}
}
pub fn get(&self, k: &str) -> Option<&Self> {
self.get_key(&k.into())
}
pub fn set_key(&mut self, k: Self, v: Self) {
if let Self::Table(t) = self {
t.retain(|(a, _)| a != &k);
if v != Self::Nil {
t.push((k, v));
}
}
}
pub fn set(&mut self, k: &str, v: Self) {
self.set_key(k.into(), v)
}
pub fn as_str(&self) -> Option<&str> {
if let Self::String(s) = self {
std::str::from_utf8(s).ok()
} else {
None
}
}
pub fn entries(&self) -> &[(Self, Self)] {
if let Self::Table(t) = self { t } else { &[] }
}
pub fn as_i64(&self) -> Option<i64> {
if let Self::Integer(v) = self {
Some(*v)
} else {
None
}
}
}
pub fn encode_name(name: &str) -> String {
encode(name, false)
}
fn encode(name: &str, store: bool) -> String {
let mut out = String::new();
for b in name.bytes() {
if b.is_ascii_alphanumeric() || (store && b == b'_') {
out.push(b as char)
} else {
out.push_str(&format!("%{b:02x}"))
}
}
out
}
pub fn path(root: &Path, host: &str, user: Option<&str>, store: &str) -> PathBuf {
let d = root.join(encode_name(host));
match user {
Some(u) => d
.join(encode(store, true))
.join(format!("{}.dat", encode_name(u))),
None => d.join(format!("{}.dat", encode(store, true))),
}
}
pub fn parse(input: &[u8]) -> Result<LuaValue> {
ensure!(input.len() <= LIMIT, "storage limit exceeded");
let lua = crate::lua::bounded_vm().map_err(|e| anyhow::anyhow!(e.to_string()))?;
let env = lua
.create_table()
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let v = lua
.load(input)
.set_mode(mlua::chunk::ChunkMode::Text)
.set_environment(env)
.eval::<mlua::Value>()
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
from_lua(v)
}
pub fn from_lua(v: mlua::Value) -> Result<LuaValue> {
fn convert(v: mlua::Value, depth: usize, count: &mut usize) -> Result<LuaValue> {
*count += 1;
ensure!(depth < 64 && *count <= 200_000, "storage complexity limit");
Ok(match v {
mlua::Value::Nil => LuaValue::Nil,
mlua::Value::Boolean(v) => LuaValue::Bool(v),
mlua::Value::Integer(v) => LuaValue::Integer(v),
mlua::Value::Number(v) => {
ensure!(v.is_finite(), "non-finite storage number");
LuaValue::Number(v)
}
mlua::Value::String(v) => LuaValue::String(v.as_bytes().to_vec()),
mlua::Value::Table(t) => {
let mut o = vec![];
for p in t.pairs::<mlua::Value, mlua::Value>() {
let (k, v) = p.map_err(|e| anyhow::anyhow!(e.to_string()))?;
ensure!(
matches!(
k,
mlua::Value::Boolean(_)
| mlua::Value::String(_)
| mlua::Value::Integer(_)
| mlua::Value::Number(_)
),
"unsupported table key"
);
o.push((convert(k, depth + 1, count)?, convert(v, depth + 1, count)?));
}
LuaValue::Table(o)
}
_ => bail!("unsupported storage value"),
})
}
convert(v, 0, &mut 0)
}
pub fn serialize(value: &LuaValue) -> Result<String> {
fn put(v: &LuaValue, s: &mut String, d: usize) -> Result<()> {
ensure!(d < 64 && s.len() <= LIMIT, "storage complexity limit");
match v {
LuaValue::Nil => s.push_str("nil"),
LuaValue::Bool(v) => s.push_str(if *v { "true" } else { "false" }),
LuaValue::Integer(v) => s.push_str(&v.to_string()),
LuaValue::Number(v) => {
ensure!(v.is_finite(), "non-finite storage number");
s.push_str(&format!("{v:?}"))
}
LuaValue::String(v) => {
s.push('"');
for b in v {
match b {
b'"' => s.push_str("\\\""),
b'\\' => s.push_str("\\\\"),
32..=126 => s.push(*b as char),
_ => s.push_str(&format!("\\{b:03}")),
}
}
s.push('"')
}
LuaValue::Table(t) => {
s.push('{');
for (k, v) in t {
ensure!(
matches!(
k,
LuaValue::Bool(_)
| LuaValue::Integer(_)
| LuaValue::Number(_)
| LuaValue::String(_)
),
"invalid key"
);
s.push('[');
put(k, s, d + 1)?;
s.push_str("]=");
put(v, s, d + 1)?;
s.push(';')
}
s.push('}')
}
}
Ok(())
}
let mut s = "return ".to_string();
put(value, &mut s, 0)?;
s.push_str(";\n");
ensure!(s.len() <= LIMIT, "storage limit exceeded");
Ok(s)
}
pub fn load(root: &Path, host: &str, user: Option<&str>, store: &str) -> Result<Option<LuaValue>> {
let p = path(root, host, user, store);
let f = match fs::File::open(&p) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let mut b = vec![];
f.take((LIMIT + 1) as u64).read_to_end(&mut b)?;
Ok(Some(parse(&b)?))
}
pub fn atomic_write(p: &Path, data: &[u8]) -> Result<()> {
let parent = p
.parent()
.ok_or_else(|| anyhow::anyhow!("missing parent"))?;
fs::create_dir_all(parent)?;
let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
let result = (|| -> Result<()> {
let mut opts = OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(&tmp)?;
f.write_all(data)?;
f.sync_all()?;
fs::rename(&tmp, p)?;
fs::File::open(parent)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(tmp);
}
result
}
pub fn save(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
value: &LuaValue,
) -> Result<()> {
if matches!(value, LuaValue::Nil) || matches!(value,LuaValue::Table(v) if v.is_empty()) {
let p = path(root, host, user, store);
match fs::remove_file(&p) {
Ok(()) => {
if let Some(parent) = p.parent() {
fs::File::open(parent)?.sync_all()?;
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
return Ok(());
}
let s = serialize(value)?;
atomic_write(&path(root, host, user, store), s.as_bytes())
}
/// Process-local serialized read/modify/write. Never run two servers on one data directory.
pub fn update<F: FnOnce(&mut LuaValue) -> Result<()>>(
root: &Path,
host: &str,
user: Option<&str>,
store: &str,
f: F,
) -> Result<LuaValue> {
let _g = TRANSACTION
.lock()
.map_err(|_| anyhow::anyhow!("storage lock poisoned"))?;
let mut v = load(root, host, user, store)?.unwrap_or_default();
ensure!(matches!(v, LuaValue::Table(_)), "store is not a table");
f(&mut v)?;
save(root, host, user, store, &v)?;
Ok(v)
}
/// util.stanza.preserialize-compatible representation (mixed numeric/string keys).
pub fn stanza_value(n: roxmltree::Node<'_, '_>) -> LuaValue {
if n.is_text() {
return n.text().unwrap_or("").into();
}
let mut t = LuaValue::table();
t.set("name", n.tag_name().name().into());
let mut attr = LuaValue::table();
attr.set("xmlns", n.tag_name().namespace().unwrap_or("").into());
for a in n.attributes() {
let key = match a.namespace() {
Some("http://www.w3.org/XML/1998/namespace") => format!("xml:{}", a.name()),
Some(ns) => format!("{ns}\u{1}{}", a.name()),
None => a.name().into(),
};
attr.set(&key, a.value().into());
}
t.set("attr", attr);
for (i, c) in n
.children()
.filter(|x| x.is_element() || x.is_text())
.enumerate()
{
t.set_key(LuaValue::Integer(i as i64 + 1), stanza_value(c));
}
t
}
pub fn stanza_xml(v: &LuaValue) -> Result<String> {
use crate::wire::escape;
if let LuaValue::String(s) = v {
return Ok(escape(std::str::from_utf8(s)?));
}
let name = v
.get("name")
.and_then(LuaValue::as_str)
.ok_or_else(|| anyhow::anyhow!("missing stanza name"))?;
ensure!(
!name.is_empty()
&& name
.chars()
.all(|c| c.is_alphanumeric() || "_-.".contains(c)),
"invalid stanza name"
);
let mut s = format!("<{name}");
let mut namespace_index = 0;
if let Some(a) = v.get("attr") {
for (k, val) in a.entries() {
let key = k
.as_str()
.ok_or_else(|| anyhow::anyhow!("invalid attribute key"))?;
let val = val
.as_str()
.ok_or_else(|| anyhow::anyhow!("invalid attribute value"))?;
let split = key.split_once('\u{1}').or_else(|| key.split_once('|'));
if let Some((ns, local)) = split {
ensure!(
!local.is_empty()
&& local
.chars()
.all(|c| c.is_alphanumeric() || "_-.".contains(c)),
"invalid attribute"
);
if ns == "http://www.w3.org/XML/1998/namespace" {
s.push_str(&format!(" xml:{local}='{}'", escape(val)))
} else {
namespace_index += 1;
s.push_str(&format!(
" xmlns:a{namespace_index}='{}' a{namespace_index}:{local}='{}'",
escape(ns),
escape(val)
))
}
} else {
ensure!(
!key.is_empty()
&& key
.chars()
.all(|c| c.is_alphanumeric() || "_-.:".contains(c)),
"invalid attribute"
);
s.push_str(&format!(" {key}='{}'", escape(val)))
}
}
}
s.push('>');
let mut i = 1;
while let Some(child) = v.get_key(&LuaValue::Integer(i)) {
s.push_str(&stanza_xml(child)?);
i += 1;
}
s.push_str(&format!("</{name}>"));
Ok(s)
}
/// Prosody datamanager.purge semantics. Caller must stop this user's sessions first.
/// Unknown stores are included, but only exact per-user storage files are removed.
pub fn purge_user(root: &Path, host: &str, user: &str) -> Result<()> {
let _guard = TRANSACTION
.lock()
.map_err(|_| anyhow::anyhow!("storage lock poisoned"))?;
ensure!(
!host.is_empty() && !user.is_empty(),
"host and user required"
);
let dir = root.join(encode_name(host));
let meta = match fs::symlink_metadata(&dir) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
};
ensure!(
meta.is_dir() && !meta.file_type().is_symlink(),
"refusing symlinked data host"
);
let user = encode_name(user);
let mut files = vec![];
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let ty = entry.file_type()?;
ensure!(
!ty.is_symlink(),
"refusing symlinked host store during account purge"
);
if !ty.is_dir() {
continue;
}
for extension in ["dat", "list", "lidx", "rappend"] {
let p = entry.path().join(format!("{user}.{extension}"));
match fs::symlink_metadata(&p) {
Ok(m) => {
ensure!(
m.is_file() || m.file_type().is_symlink(),
"account store path is not a file"
);
files.push(p)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
}
}
// Revoke login before deleting the remainder; failures are returned, never hidden.
files.sort_by_key(|p| {
if p.parent()
.and_then(|p| p.file_name())
.is_some_and(|s| s == "accounts")
{
0
} else {
1
}
});
let mut errors = vec![];
for p in files {
match fs::remove_file(&p) {
Ok(()) => {
if let Some(parent) = p.parent()
&& let Err(e) = fs::File::open(parent).and_then(|f| f.sync_all())
{
errors.push(e.to_string())
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => errors.push(e.to_string()),
}
}
ensure!(
errors.is_empty(),
"account purge failed: {}",
errors.join("; ")
);
Ok(())
}
/// Coordinate account creation with key/value updates and user purges.
/// The callback must not call `update`, `purge_user`, or this function recursively.
pub fn with_exclusive_access<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
let _guard = TRANSACTION
.lock()
.map_err(|_| anyhow::anyhow!("storage lock poisoned"))?;
f()
}
+222
View File
@@ -0,0 +1,222 @@
//! RFC 5929 `tls-server-end-point` for an already validated leaf certificate.
//!
//! This only reads the outer Certificate/AlgorithmIdentifier envelope, not X.509
//! names, extensions, keys, or signatures. Certificate validation belongs to TLS.
//! RFC 9266 adds `tls-exporter`; it does not redefine this certificate digest.
use anyhow::{Result, bail, ensure};
use sha2::{Digest, Sha224, Sha256, Sha384, Sha512};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Hash {
Md5,
Sha1,
Sha224,
Sha256,
Sha384,
Sha512,
}
/// Hash the exact DER leaf certificate according to its outer signature algorithm.
/// Unsupported algorithms (including EdDSA) and malformed envelopes return errors;
/// there is deliberately no fallback to an arbitrary digest.
pub fn server_end_point(cert_der: &[u8]) -> Result<Vec<u8>> {
let mut input = Der(cert_der);
let mut certificate = Der(input.take(0x30)?);
input.finish()?;
certificate.take(0x30)?; // TBSCertificate is opaque to this envelope parser.
let algorithm = certificate.take(0x30)?;
let signature = certificate.take(0x03)?;
ensure!(
signature.len() > 1 && signature[0] == 0,
"invalid certificate signature BIT STRING"
);
certificate.finish()?;
let (oid, params) = algorithm_identifier(algorithm)?;
let hash = if oid == b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0a" {
pss_hash(params)?
} else {
// RSA PKCS#1 v1.5 signature identifiers.
let hash = match oid {
b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x04" => Hash::Md5,
b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x05" | b"\x2b\x0e\x03\x02\x1d" => Hash::Sha1,
b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0e" => Hash::Sha224,
b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0b" => Hash::Sha256,
b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0c" => Hash::Sha384,
b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0d" => Hash::Sha512,
// DSA and ECDSA signature identifiers.
b"\x2a\x86\x48\xce\x38\x04\x03" | b"\x2a\x86\x48\xce\x3d\x04\x01" => Hash::Sha1,
b"\x60\x86\x48\x01\x65\x03\x04\x03\x01" | b"\x2a\x86\x48\xce\x3d\x04\x03\x01" => {
Hash::Sha224
}
b"\x60\x86\x48\x01\x65\x03\x04\x03\x02" | b"\x2a\x86\x48\xce\x3d\x04\x03\x02" => {
Hash::Sha256
}
b"\x60\x86\x48\x01\x65\x03\x04\x03\x03" | b"\x2a\x86\x48\xce\x3d\x04\x03\x03" => {
Hash::Sha384
}
b"\x60\x86\x48\x01\x65\x03\x04\x03\x04" | b"\x2a\x86\x48\xce\x3d\x04\x03\x04" => {
Hash::Sha512
}
_ => bail!("unsupported certificate signature algorithm for tls-server-end-point"),
};
null_or_absent(params)?;
hash
};
Ok(match hash {
Hash::Md5 | Hash::Sha1 | Hash::Sha256 => Sha256::digest(cert_der).to_vec(),
Hash::Sha224 => Sha224::digest(cert_der).to_vec(),
Hash::Sha384 => Sha384::digest(cert_der).to_vec(),
Hash::Sha512 => Sha512::digest(cert_der).to_vec(),
})
}
type AlgorithmIdentifier<'a> = (&'a [u8], Option<(u8, &'a [u8])>);
fn algorithm_identifier(bytes: &[u8]) -> Result<AlgorithmIdentifier<'_>> {
let mut der = Der(bytes);
let oid = der.take(0x06)?;
ensure!(!oid.is_empty(), "empty algorithm OID");
// Reject noncanonical/truncated base-128 OID components.
let mut start = true;
for &byte in oid {
ensure!(!start || byte != 0x80, "noncanonical algorithm OID");
start = byte & 0x80 == 0;
}
ensure!(start, "truncated algorithm OID");
let params = if der.0.is_empty() {
None
} else {
Some(der.any()?)
};
der.finish()?;
Ok((oid, params))
}
fn null_or_absent(params: Option<(u8, &[u8])>) -> Result<()> {
ensure!(
params.is_none() || params == Some((0x05, &[])),
"invalid algorithm parameters"
);
Ok(())
}
fn hash_algorithm(bytes: &[u8]) -> Result<Hash> {
let (oid, params) = algorithm_identifier(bytes)?;
null_or_absent(params)?;
Ok(match oid {
b"\x2b\x0e\x03\x02\x1a" => Hash::Sha1,
b"\x60\x86\x48\x01\x65\x03\x04\x02\x04" => Hash::Sha224,
b"\x60\x86\x48\x01\x65\x03\x04\x02\x01" => Hash::Sha256,
b"\x60\x86\x48\x01\x65\x03\x04\x02\x02" => Hash::Sha384,
b"\x60\x86\x48\x01\x65\x03\x04\x02\x03" => Hash::Sha512,
_ => bail!("unsupported RSA-PSS hash algorithm"),
})
}
fn explicit_sequence(bytes: &[u8]) -> Result<&[u8]> {
let mut der = Der(bytes);
let value = der.take(0x30)?;
der.finish()?;
Ok(value)
}
fn pss_hash(params: Option<(u8, &[u8])>) -> Result<Hash> {
let Some((0x30, bytes)) = params else {
bail!("missing or invalid RSA-PSS parameters")
};
let mut der = Der(bytes);
let mut hash = Hash::Sha1;
let mut mgf_hash = Hash::Sha1;
let mut previous = 0x9f;
while !der.0.is_empty() {
let (tag, value) = der.any()?;
ensure!(
(0xa0..=0xa3).contains(&tag) && tag > previous,
"invalid RSA-PSS parameter order"
);
previous = tag;
match tag {
0xa0 => hash = hash_algorithm(explicit_sequence(value)?)?,
0xa1 => {
let (oid, params) = algorithm_identifier(explicit_sequence(value)?)?;
ensure!(
oid == b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x08",
"unsupported RSA-PSS mask generation algorithm"
);
let Some((0x30, hash_params)) = params else {
bail!("invalid MGF1 parameters")
};
mgf_hash = hash_algorithm(hash_params)?;
}
0xa2 | 0xa3 => {
let mut integer = Der(value);
let bytes = integer.take(0x02)?;
integer.finish()?;
ensure!(
!bytes.is_empty() && bytes[0] & 0x80 == 0,
"invalid RSA-PSS integer"
);
ensure!(
bytes.len() == 1 || bytes[0] != 0 || bytes[1] & 0x80 != 0,
"noncanonical RSA-PSS integer"
);
if tag == 0xa3 {
ensure!(bytes == [1], "unsupported RSA-PSS trailer field");
}
}
_ => unreachable!(),
}
}
// RFC 5929 leaves signatures using multiple hash functions undefined.
ensure!(hash == mgf_hash, "RSA-PSS uses multiple hash functions");
Ok(hash)
}
/// Nonrecursive, zero-copy DER TLV reader. Length arithmetic is bounded by input.
struct Der<'a>(&'a [u8]);
impl<'a> Der<'a> {
fn any(&mut self) -> Result<(u8, &'a [u8])> {
ensure!(self.0.len() >= 2, "truncated DER header");
let tag = self.0[0];
ensure!(tag & 0x1f != 0x1f && tag != 0, "unsupported DER tag");
let first = self.0[1];
let mut offset = 2;
let length = if first & 0x80 == 0 {
usize::from(first)
} else {
let count = usize::from(first & 0x7f);
ensure!(
count > 0 && count <= std::mem::size_of::<usize>(),
"invalid DER length"
);
ensure!(
count <= self.0.len() - offset && self.0[offset] != 0,
"truncated or noncanonical DER length"
);
let mut length = 0usize;
for &byte in &self.0[offset..offset + count] {
length = length
.checked_mul(256)
.and_then(|n| n.checked_add(usize::from(byte)))
.ok_or_else(|| anyhow::anyhow!("DER length overflow"))?;
}
offset += count;
ensure!(length >= 128, "noncanonical DER length");
length
};
ensure!(length <= self.0.len() - offset, "truncated DER value");
let (value, rest) = self.0[offset..].split_at(length);
self.0 = rest;
Ok((tag, value))
}
fn take(&mut self, expected: u8) -> Result<&'a [u8]> {
let (tag, value) = self.any()?;
ensure!(tag == expected, "unexpected DER tag");
Ok(value)
}
fn finish(&self) -> Result<()> {
ensure!(self.0.is_empty(), "trailing DER data");
Ok(())
}
}
+267
View File
@@ -0,0 +1,267 @@
use anyhow::{Result, bail, ensure};
use std::collections::BTreeMap;
use tokio::io::{AsyncRead, AsyncReadExt};
const MAX: usize = 256 * 1024;
pub const STREAM_NAMESPACE: &str = "http://etherx.jabber.org/streams";
#[derive(Clone, Debug)]
pub struct StreamHeader {
pub name: String,
pub to: Option<String>,
pub from: Option<String>,
pub version: Option<String>,
/// Empty key denotes the default namespace.
pub namespaces: BTreeMap<String, String>,
}
impl StreamHeader {
pub fn parse(text: &str) -> Result<Self> {
let text = text.trim();
ensure!(
text.starts_with('<') && !text.starts_with("</") && !text.ends_with("/>"),
"expected open stream header"
);
let name =
tag_name(text.as_bytes()).ok_or_else(|| anyhow::anyhow!("missing stream name"))?;
ensure!(
name.rsplit(':').next() == Some("stream"),
"expected stream element"
);
let document = format!("{text}</{name}>");
let doc = roxmltree::Document::parse(&document)?;
let root = doc.root_element();
ensure!(
root.tag_name().namespace() == Some(STREAM_NAMESPACE)
&& root.tag_name().name() == "stream",
"invalid stream namespace"
);
Ok(Self {
name: name.into(),
to: root.attribute("to").map(str::to_owned),
from: root.attribute("from").map(str::to_owned),
version: root.attribute("version").map(str::to_owned),
namespaces: root
.namespaces()
.map(|n| (n.name().unwrap_or("").into(), n.uri().into()))
.collect(),
})
}
pub fn default_namespace(&self) -> Option<&str> {
self.namespaces.get("").map(String::as_str)
}
}
/// Incremental framing. Every emitted stanza must still undergo full XML validation.
#[derive(Default)]
pub struct Framer {
pub buffer: Vec<u8>,
pub header: Option<StreamHeader>,
}
impl Framer {
pub fn wrap(&self, xml: &str) -> String {
let mut out = "<root".to_owned();
if let Some(header) = &self.header {
for (prefix, uri) in &header.namespaces {
if prefix == "xml" {
continue;
}
if prefix.is_empty() {
out.push_str(&format!(" xmlns='{}'", escape(uri)))
} else {
out.push_str(&format!(" xmlns:{}='{}'", prefix, escape(uri)))
}
}
}
out.push('>');
out.push_str(xml);
out.push_str("</root>");
out
}
pub fn is_stream_end(&self, text: &str) -> bool {
self.header
.as_ref()
.is_some_and(|h| text.trim() == format!("</{}>", h.name))
}
pub async fn next<S: AsyncRead + Unpin>(&mut self, s: &mut S) -> Result<String> {
loop {
if let Some(n) = boundary(&self.buffer)? {
let bytes: Vec<_> = self.buffer.drain(..n).collect();
let text = String::from_utf8(bytes)?.trim().to_owned();
if text.starts_with("<!--") {
let wrapped = format!("<root>{text}</root>");
let _ = roxmltree::Document::parse(&wrapped)?;
continue;
}
if text.starts_with("<?xml") {
let doc = format!("{text}<root/>");
let _ = roxmltree::Document::parse(&doc)?;
return Ok(text);
}
if !text.starts_with("</")
&& tag_name(text.as_bytes())
.is_some_and(|n| n.rsplit(':').next() == Some("stream"))
{
self.header = Some(StreamHeader::parse(&text)?);
}
return Ok(text);
}
ensure!(self.buffer.len() < MAX, "stanza too large");
let mut chunk = [0u8; 4096];
let n = s.read(&mut chunk).await?;
ensure!(n > 0, "connection closed");
self.buffer.extend_from_slice(&chunk[..n]);
}
}
}
fn tag_name(tag: &[u8]) -> Option<&str> {
let start = if tag.starts_with(b"</") {
2
} else if tag.starts_with(b"<") {
1
} else {
return None;
};
let end = tag[start..]
.iter()
.position(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
.map(|i| start + i)?;
std::str::from_utf8(&tag[start..end]).ok()
}
fn boundary(b: &[u8]) -> Result<Option<usize>> {
ensure!(b.len() <= MAX, "stanza too large");
let mut i = 0;
let mut depth = 0usize;
while i < b.len() {
if b[i] != b'<' {
if depth == 0 {
ensure!(b[i].is_ascii_whitespace(), "text outside stanza")
}
i += 1;
continue;
}
let start = i;
if b[i..].starts_with(b"<!--") {
if let Some(p) = b[i + 4..].windows(3).position(|w| w == b"-->") {
i = i + 4 + p + 3;
if depth == 0 {
return Ok(Some(i));
}
continue;
}
return Ok(None);
}
if b[i..].starts_with(b"<![CDATA[") {
ensure!(depth > 0, "CDATA outside stanza");
if let Some(p) = b[i + 9..].windows(3).position(|w| w == b"]]>") {
i = i + 9 + p + 3;
continue;
}
return Ok(None);
}
if b[i..].starts_with(b"<!") {
if b"<!--".starts_with(&b[i..]) || b"<![CDATA[".starts_with(&b[i..]) {
return Ok(None);
}
bail!("DTD/entity declarations forbidden")
}
let mut quote = 0;
i += 1;
while i < b.len() {
let c = b[i];
if quote != 0 {
if c == quote {
quote = 0
}
} else if c == b'\'' || c == b'"' {
quote = c
} else if c == b'>' {
break;
}
i += 1
}
if i == b.len() {
return Ok(None);
}
let end = i + 1;
let tag = &b[start..end];
if tag.starts_with(b"<?xml")
&& tag.get(5).is_some_and(|b| b.is_ascii_whitespace())
&& depth == 0
{
ensure!(tag.ends_with(b"?>"), "invalid XML declaration");
return Ok(Some(end));
}
ensure!(!tag.starts_with(b"<?"), "processing instructions forbidden");
if tag_name(tag).is_some_and(|name| name.rsplit(':').next() == Some("stream")) && depth == 0
{
return Ok(Some(end));
}
if tag.starts_with(b"</") {
ensure!(depth > 0, "unexpected closing tag");
depth -= 1
} else if !tag.ends_with(b"/>") {
depth += 1;
ensure!(depth <= 32, "XML nesting limit")
}
if depth == 0 {
return Ok(Some(end));
}
i = end;
}
Ok(None)
}
pub fn escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fragmented_and_pipelined() {
assert_eq!(boundary(b"<message><body>hi</body>").unwrap(), None);
assert_eq!(
boundary(b"<message><body>hi</body></message><presence/>").unwrap(),
Some(34)
);
}
#[test]
fn declarations_rejected() {
assert!(boundary(b"<!DOCTYPE x>").is_err());
assert!(boundary(b"<!ENTITY x 'y'>").is_err());
}
#[test]
fn cdata_and_comments_frame_without_counting_brackets() {
let xml = b"<message><body><![CDATA[<x> & ] x]]></body><!-- <y> --></message>";
assert_eq!(boundary(xml).unwrap(), Some(xml.len()));
for i in 0..xml.len() {
assert_eq!(boundary(&xml[..i]).unwrap(), None, "{i}");
}
}
#[tokio::test]
async fn arbitrary_prefix_and_inherited_namespaces() {
let xml = "<?xml version='1.0'?><!-- hello --><s:stream xmlns='jabber:client' xmlns:s='http://etherx.jabber.org/streams' xmlns:a='urn:test' to='example.test' version='1.0'><iq id='a'><a:test/></iq></s:stream>";
let mut input = xml.as_bytes();
let mut f = Framer::default();
assert!(f.next(&mut input).await.unwrap().starts_with("<?xml"));
let header = f.next(&mut input).await.unwrap();
assert_eq!(StreamHeader::parse(&header).unwrap().name, "s:stream");
let stanza = f.next(&mut input).await.unwrap();
let wrapped = f.wrap(&stanza);
let doc = roxmltree::Document::parse(&wrapped).unwrap();
assert!(
doc.descendants()
.any(|n| n.has_tag_name(("urn:test", "test")))
);
let end = f.next(&mut input).await.unwrap();
assert!(f.is_stream_end(&end));
}
#[tokio::test]
async fn invalid_namespace_and_declarations_rejected() {
let mut f = Framer::default();
let mut input = "<s:stream xmlns:s='urn:evil'>".as_bytes();
assert!(f.next(&mut input).await.is_err());
let mut input = "<?xml version='1.0' broken='yes'?>".as_bytes();
assert!(Framer::default().next(&mut input).await.is_err());
}
}