Files
rust-xmpp/src/muc_admin.rs
T
wt 7f60661eef - MUC сохраняет согласованный журнал конфигурации, affiliations, nick,
ролей,
  членства и ограниченной истории без MAM. Изменение фиксируется до
  публикации
  ответа; при ошибке записи состояние комнаты откатывается.
- Crash recovery использует последнее сохранённое поколение после
  SIGKILL,
  включая временные комнаты. Heartbeat обновляет живое членство;
  ожидающие записи сохраняют исходный deadline через повторные
  перезапуски.
  `muc_recovery_grace_period`: по умолчанию 300 секунд, пределы
  1..604800.
- Подтверждённые выходы, смена nick, ролей и политик комнаты не
  заменяются
  старой записью восстановления. Native config/state остаются экспортом
  для
  Prosody; согласованное восстановление Rust использует собственный
  sidecar.
- Исправлены MUC probe, неизвестные типы presence, вход без MUC `<x/>`,
  ошибки
  доставки и статус 333. Обычные presence extensions сохраняются в живом
  обмене; поддельная MUC identity фильтруется. `subject` вместе с `body`
  либо
  `thread` не изменяет тему комнаты.
- Подключены `muc_max_rooms`, `restrict_room_creation`, политики
  регистрации
  и зарегистрированного nick, `muc_tombstone_expiry`; добавлены
  встроенные
  административные ad-hoc команды уничтожения комнат и изменения
  affiliation.
- Hub сохраняет resumable XEP-0198 сессии, счётчики и очередь. Resume
  после
  перезапуска доступен на новом TLS/SASL-транспорте через legacy SASL и
  SASL2.
  Действующие приватные SM-записи восстанавливают логические сессии и
  MUC
  до открытия listeners: offline-доставка попадает в очередь,
  синтетический
  join и история повторно не отправляются. SM resume не требует
  дополнительной
  available presence; без SM восстановление MUC требует новой
  аутентификации
  точного полного JID и available presence либо явного MUC-входа.
  Входящий handled-счётчик обновляется после обработки. Сокеты TCP/TLS
  не сохраняются; неопределённая сетевая отправка допускает повторную
  доставку.
- Известные встроенные MUC-возможности Prosody отображаются на
  Rust-реализацию.
  Произвольные Lua/community-модули не исполняются; неизвестные явно
  включённые
  MUC-модули отклоняются при запуске. Общие и host `modules_disabled`
  учитываются
  для vCard и occupant-id.

Порядок восстановления, ограничения хранения и отката на Prosody:
[docs/MUC-RECOVERY.md](docs/MUC-RECOVERY.md).

Проверка: 293 Rust tests, Clippy/fmt/release, полный сетевой прогон, 500
TLS
MUC+SM ресурсов с SIGKILL/resume и native reference — успешно.
Подробности: [docs/VALIDATION-0.8.0.md](docs/VALIDATION-0.8.0.md).
2026-10-20 17:34:43 +07:00

320 lines
12 KiB
Rust

//! The two native MUC component administration commands. Authority belongs to
//! configured component administrators and is checked again on every step.
use super::*;
const COMMANDS: &str = "http://jabber.org/protocol/commands";
const DESTROY: &str = "http://prosody.im/protocol/muc#destroy";
const AFFILIATION: &str = "http://prosody.im/protocol/muc#set-affiliation";
const INFO: &str = "http://jabber.org/protocol/disco#info";
const ITEMS: &str = "http://jabber.org/protocol/disco#items";
pub(super) fn period(value: Option<&Value>) -> Option<f64> {
let seconds = if let Some(number) = value.and_then(Value::as_f64) {
number
} else {
let text = value?.as_str()?.trim();
let split = text
.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '+' || c == '-'))
.unwrap_or(text.len());
let number: f64 = text[..split].trim().parse().ok()?;
let unit = text[split..].trim();
number
* match unit {
"" | "s" | "second" | "seconds" => 1.0,
"m" | "minute" | "minutes" => 60.0,
"h" | "hour" | "hours" => 3600.0,
"d" | "day" | "days" => 86400.0,
"w" | "week" | "weeks" => 604800.0,
_ => return None,
}
};
(seconds.is_finite() && seconds >= 0.0).then_some(seconds)
}
pub(super) fn registration_form(nick: Option<&str>) -> String {
format!(
"<x xmlns='jabber:x:data' type='{}'><field var='FORM_TYPE' type='hidden'><value>http://jabber.org/protocol/muc#register</value></field><field var='muc#register_roomnick' type='text-single'>{}<required/></field></x>",
if nick.is_some() { "result" } else { "form" },
nick.map(|v| format!("<value>{}</value>", escape(v)))
.unwrap_or_default()
)
}
fn command_reply(
stanza: roxmltree::Node,
node: &str,
session: &str,
status: &str,
body: &str,
) -> ModuleResult {
ModuleResult::reply(iq_result(
stanza,
&format!(
"<command xmlns='{COMMANDS}' node='{}' sessionid='{}' status='{status}'>{body}</command>",
escape(node),
escape(session)
),
))
}
fn form(muc: &Muc, node: &str) -> String {
let fields = if node == DESTROY {
let rooms = muc
.rooms
.keys()
.map(|room| {
format!(
"<option label='{}'><value>{}</value></option>",
escape(room),
escape(room)
)
})
.collect::<String>();
format!(
"<field var='rooms' type='list-multi' label='Rooms to destroy'><required/>{rooms}</field>"
)
} else {
"<field var='room' type='jid-single' label='Room'><required/></field><field var='jid' type='jid-single' label='JID'><required/></field><field var='affiliation' type='list-single' label='Affiliation'><required/><option><value>owner</value></option><option><value>admin</value></option><option><value>member</value></option><option><value>none</value></option><option><value>outcast</value></option></field><field var='reason' type='text-single' label='Reason'/>".to_owned()
};
format!(
"<actions execute='complete'><complete/></actions><x xmlns='jabber:x:data' type='form'><title>{}</title><field var='FORM_TYPE' type='hidden'><value>{node}</value></field>{fields}</x>",
if node == DESTROY {
"Destroy rooms"
} else {
"Set affiliation"
}
)
}
pub(super) fn handle(
muc: &mut Muc,
jid: &str,
stanza: roxmltree::Node,
) -> Result<Option<ModuleResult>> {
if stanza.tag_name().name() != "iq" {
return Ok(None);
}
let Some(query) = stanza.children().find(|n| n.is_element()) else {
return Ok(None);
};
let ns = query.tag_name().namespace().unwrap_or("");
let node = query.attribute("node").unwrap_or("");
let is_admin = muc
.component_admins
.iter()
.any(|admin| bare(admin) == bare(jid));
if matches!(ns, INFO | ITEMS)
&& (node == COMMANDS || matches!(node, DESTROY | AFFILIATION))
&& stanza.attribute("type") == Some("get")
{
let body = if ns == ITEMS && node == COMMANDS {
if is_admin {
format!(
"<item jid='{}' node='{DESTROY}' name='Destroy Rooms'/><item jid='{}' node='{AFFILIATION}' name='Set affiliation in room'/>",
escape(&muc.domain),
escape(&muc.domain)
)
} else {
String::new()
}
} else if !is_admin && node != COMMANDS {
return Ok(Some(ModuleResult::reply(iq_error(stanza, "forbidden"))));
} else if ns == INFO {
format!(
"<identity category='automation' type='{}'/><feature var='{COMMANDS}'/><feature var='jabber:x:data'/>",
if node == COMMANDS {
"command-list"
} else {
"command-node"
}
)
} else {
String::new()
};
return Ok(Some(ModuleResult::reply(iq_result(
stanza,
&format!("<query xmlns='{ns}' node='{}'>{body}</query>", escape(node)),
))));
}
if ns != COMMANDS || query.tag_name().name() != "command" {
return Ok(None);
}
if !is_admin {
return Ok(Some(ModuleResult::reply(iq_error(stanza, "forbidden"))));
}
if stanza.attribute("type") != Some("set") {
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
}
if !matches!(node, DESTROY | AFFILIATION) {
return Ok(Some(ModuleResult::reply(iq_error(
stanza,
"item-not-found",
))));
}
muc.command_sessions
.retain(|_, (_, _, started)| started.elapsed() < std::time::Duration::from_secs(600));
let session = query.attribute("sessionid");
let action = query.attribute("action").unwrap_or("execute");
if session.is_none() {
if action != "execute"
|| query
.children()
.any(|n| n.has_tag_name(("jabber:x:data", "x")))
{
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
}
if muc.command_sessions.len() >= 1000 {
return Ok(Some(ModuleResult::reply(iq_error(
stanza,
"resource-constraint",
))));
}
let id = uuid::Uuid::new_v4().to_string();
muc.command_sessions.insert(
id.clone(),
(
bare(jid).to_owned(),
node.to_owned(),
std::time::Instant::now(),
),
);
return Ok(Some(command_reply(
stanza,
node,
&id,
"executing",
&form(muc, node),
)));
}
let session = session.unwrap();
if !muc
.command_sessions
.get(session)
.is_some_and(|(owner, command, _)| owner == bare(jid) && command == node)
{
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
}
if action == "cancel" {
muc.command_sessions.remove(session);
return Ok(Some(command_reply(stanza, node, session, "canceled", "")));
}
if !matches!(action, "execute" | "complete") {
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
}
let Some(data) = query
.children()
.find(|n| n.has_tag_name(("jabber:x:data", "x")) && n.attribute("type") == Some("submit"))
else {
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
};
let values = |name: &str| {
data.children()
.filter(|f| {
f.has_tag_name(("jabber:x:data", "field")) && f.attribute("var") == Some(name)
})
.flat_map(|f| {
f.children()
.filter(|v| v.has_tag_name(("jabber:x:data", "value")))
.filter_map(|v| v.text())
})
.collect::<Vec<_>>()
};
if values("FORM_TYPE") != vec![node] {
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
}
let mut out = ModuleResult::default();
if node == DESTROY {
let rooms = values("rooms");
if rooms.is_empty()
|| rooms
.iter()
.any(|room| bare(room) != *room || !room.ends_with(&format!("@{}", muc.domain)))
{
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
}
for name in rooms {
muc.load(name)?;
if !muc.rooms.contains_key(name) {
continue;
}
// Only this internal, ACL-checked command bypasses room ownership.
let packet = format!(
"<iq type='set' to='{}'><query xmlns='{OWNER}'><destroy/></query></iq>",
escape(name)
);
let doc = roxmltree::Document::parse(&packet)?;
if let Some(mut destroyed) =
muc.handle_inner_authorized(jid, doc.root_element(), true)?
{
out.deliveries.append(&mut destroyed.deliveries);
}
}
} else {
let rooms = values("room");
let targets = values("jid");
let affiliations = values("affiliation");
if rooms.len() != 1 || targets.len() != 1 || affiliations.len() != 1 {
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
}
let name = rooms[0];
let target = targets[0];
let affiliation = affiliations[0];
if bare(name) != name
|| !name.ends_with(&format!("@{}", muc.domain))
|| crate::account_modules::normalize_bare_jid(bare(target)).is_err()
|| !matches!(
affiliation,
"none" | "member" | "admin" | "owner" | "outcast"
)
{
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
}
muc.load(name)?;
let Some(room) = muc.rooms.get_mut(name) else {
return Ok(Some(command_reply(
stanza,
node,
session,
"canceled",
"<note type='error'>No such room</note>",
)));
};
let reason = values("reason").first().copied().unwrap_or("");
let packet = format!(
"<iq type='set'><query xmlns='{ADMIN}'><item jid='{}' affiliation='{affiliation}'><reason>{}</reason></item></query></iq>",
escape(&crate::account_modules::normalize_bare_jid(bare(target))?),
escape(reason)
);
let doc = roxmltree::Document::parse(&packet)?;
let iq = doc.root_element();
let mut applied = ModuleResult::default();
admin_with_authority(
room,
name,
jid,
iq,
iq.children().find(|n| n.is_element()).unwrap(),
&mut applied,
true,
);
if applied.replies.iter().any(|p| p.contains("type='error'")) {
return Ok(Some(command_reply(
stanza,
node,
session,
"canceled",
"<note type='error'>Affiliation change failed</note>",
)));
}
out.deliveries.append(&mut applied.deliveries);
muc.save(name)?;
}
muc.command_sessions.remove(session);
out.replies.append(
&mut command_reply(
stanza,
node,
session,
"completed",
"<note type='info'>Command completed</note>",
)
.replies,
);
Ok(Some(out))
}