initial commit
This commit is contained in:
commit
c260d7bab8
235 files changed
+49786
No files matched your search
File diff suppressed because it is too large.
Load diff
+652
@@ -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
@@ -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(×tamp(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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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(×tamp),
|
||||
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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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"))
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+451
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+478
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user