s2s: handle echo's metadata profile keys, standard replies, OPERTYPE and REDACT; emit ssl_cert + OPERTYPE
This commit is contained in:
parent
8f765bfba7
commit
5722640957
4 changed files with 157 additions and 12 deletions
124
src/link.rs
124
src/link.rs
|
|
@ -201,6 +201,9 @@ impl Server {
|
|||
"METADATA" if registered => self.link_metadata(uid, msg),
|
||||
"CHGHOST" if registered => self.link_chghost_recv(uid, msg),
|
||||
"CHGIDENT" if registered => self.link_chgident_recv(uid, msg),
|
||||
"SWSTDRPL" if registered => self.link_stdreply_recv(uid, msg),
|
||||
"OPERTYPE" if registered => self.link_opertype_recv(uid, msg),
|
||||
"REDACT" if registered => self.link_redact_recv(uid, msg),
|
||||
"SASL" if registered => self.link_sasl(uid, msg),
|
||||
"BURST" => {
|
||||
if let Some(l) = self.links.get_mut(&uid) {
|
||||
|
|
@ -424,6 +427,11 @@ impl Server {
|
|||
if let Some(acct) = &u.account {
|
||||
v.push(format!(":{} METADATA {} accountname :{acct}", self.sid, u.uuid));
|
||||
}
|
||||
// ssl_cert so services learn the client's TLS fingerprint (cert auto-login,
|
||||
// fingerprint extbans). Flags `vsT` = valid/secure/trusted; no `E` (error).
|
||||
if let Some(fp) = &u.certfp {
|
||||
v.push(format!(":{} METADATA {} ssl_cert :vsT {fp}", self.sid, u.uuid));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
|
|
@ -839,26 +847,118 @@ impl Server {
|
|||
/// `:src METADATA <target> <key> :<value>` — services sync metadata onto a
|
||||
/// user. We apply `accountname` (login/logout); other keys are accepted and
|
||||
/// ignored for now. Forwarded on if the target is remote.
|
||||
/// `ENCAP * SWSTDRPL <client-uuid> <src|*> <FAIL|WARN|NOTE> <command|*> <code> :<text>`
|
||||
/// — a services IRCv3 standard reply, re-emitted locally to the target client (or
|
||||
/// forwarded on). Without this a `standard-replies` client sees no feedback when a
|
||||
/// services command fails (e.g. a bad NickServ IDENTIFY).
|
||||
fn link_stdreply_recv(&mut self, from: Uid, msg: &Message) {
|
||||
if msg.params.len() < 5 {
|
||||
return;
|
||||
}
|
||||
let to = msg.params[0].clone();
|
||||
let Some(&dst) = self.uuid_local.get(&to) else {
|
||||
self.forward_to_target(&to, msg, from);
|
||||
return;
|
||||
};
|
||||
let (command, code) = (msg.params[3].clone(), msg.params[4].clone());
|
||||
let text = msg.params.get(5).cloned().unwrap_or_default();
|
||||
match msg.params[2].as_str() {
|
||||
"WARN" => self.warn(dst, &command, &code, &text),
|
||||
"NOTE" => self.note(dst, &command, &code, &text),
|
||||
_ => self.fail(dst, &command, &code, &text),
|
||||
}
|
||||
}
|
||||
|
||||
/// `:<uuid> OPERTYPE :<type>` — a remote user opered up; reflect it on their modes
|
||||
/// so the network's view of who is an operator stays consistent.
|
||||
fn link_opertype_recv(&mut self, _from: Uid, msg: &Message) {
|
||||
if let Some(src) = msg.source.as_deref() {
|
||||
if let Some(ru) = self.remote_users.get_mut(src) {
|
||||
if !ru.modes.contains('o') {
|
||||
ru.modes.push('o');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `:<uuid> REDACT <#chan> <msgid> [:reason]` — a services/remote message deletion:
|
||||
/// relay it to local channel members who understand draft/message-redaction, drop it
|
||||
/// from CHATHISTORY, and forward to other links with members there.
|
||||
fn link_redact_recv(&mut self, via: Uid, msg: &Message) {
|
||||
if msg.params.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let Some(src) = msg.source.clone() else {
|
||||
return;
|
||||
};
|
||||
let (target, msgid) = (msg.params[0].clone(), msg.params[1].clone());
|
||||
if !target.starts_with('#') {
|
||||
return;
|
||||
}
|
||||
let key = target.to_ascii_lowercase();
|
||||
let Some(prefix) = self
|
||||
.uuid_prefix(&src)
|
||||
.or_else(|| self.servers.get(&src).map(|s| s.name.clone()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.channels.contains_key(&key) {
|
||||
let line = match msg.params.get(2) {
|
||||
Some(r) => format!(":{prefix} REDACT {target} {msgid} :{r}"),
|
||||
None => format!(":{prefix} REDACT {target} {msgid}"),
|
||||
};
|
||||
let members: Vec<Uid> = self.channels[&key].members.keys().copied().collect();
|
||||
for m in members {
|
||||
if self.users.get(&m).map(|u| u.caps.message_redaction).unwrap_or(false) {
|
||||
self.send(m, line.clone());
|
||||
}
|
||||
}
|
||||
for l in self.channel_link_targets(&key, Some(via)) {
|
||||
self.link_out(l, msg.to_wire());
|
||||
}
|
||||
}
|
||||
crate::modules::chathistory::forget(self, &key, &msgid);
|
||||
}
|
||||
|
||||
fn link_metadata(&mut self, from: Uid, msg: &Message) {
|
||||
if msg.params.len() < 3 {
|
||||
return;
|
||||
}
|
||||
let (target, key, value) = (&msg.params[0], &msg.params[1], &msg.params[2]);
|
||||
let Some(tuid) = self.link_local_target(target) else {
|
||||
self.forward_to_target(target, msg, from);
|
||||
let (target, key, value) = (
|
||||
msg.params[0].clone(),
|
||||
msg.params[1].clone(),
|
||||
msg.params[2].clone(),
|
||||
);
|
||||
let Some(tuid) = self.link_local_target(&target) else {
|
||||
self.forward_to_target(&target, msg, from);
|
||||
return;
|
||||
};
|
||||
if key == "accountname" {
|
||||
// account login is a services authority: ignore it from an ordinary peer
|
||||
// (each hop re-checks, so forwarding an unauthorised one stays harmless).
|
||||
if !self.source_is_service(msg) {
|
||||
return;
|
||||
// Metadata pushed onto a local user (login state, profile fields) is a
|
||||
// services authority: ignore it from an ordinary peer. Each hop re-checks,
|
||||
// so forwarding an unauthorised one stays harmless.
|
||||
if !self.source_is_service(msg) {
|
||||
return;
|
||||
}
|
||||
match key.as_str() {
|
||||
"accountname" => {
|
||||
if value.is_empty() || value == "*" {
|
||||
self.logout(tuid);
|
||||
} else {
|
||||
self.set_login(tuid, &value);
|
||||
}
|
||||
}
|
||||
if value.is_empty() || value == "*" {
|
||||
self.logout(tuid);
|
||||
} else {
|
||||
self.set_login(tuid, value);
|
||||
// profile fields NickServ SET populates — surface them over metadata-2
|
||||
"avatar" | "bio" | "pronouns" | "timezone" | "url" => {
|
||||
let nick = self.users.get(&tuid).map(|u| u.nick.clone()).unwrap_or_default();
|
||||
let setter = msg
|
||||
.source
|
||||
.as_deref()
|
||||
.and_then(|src| self.servers.get(src).map(|sv| sv.name.clone()))
|
||||
.unwrap_or_else(|| self.name.clone());
|
||||
let v = (!value.is_empty()).then_some(value.as_str());
|
||||
crate::modules::metadata::apply_user(self, tuid, &nick, &key, v, &setter);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,16 @@ pub fn record(
|
|||
}
|
||||
}
|
||||
|
||||
/// Drop a message from a conversation's history by msgid (a redaction), so replays
|
||||
/// don't resurrect it. Used by both the client REDACT command and an inbound S2S REDACT.
|
||||
pub fn forget(s: &mut Server, key: &str, msgid: &str) {
|
||||
if let Some(h) = s.ext.get_mut::<History>() {
|
||||
if let Some(buf) = h.0.get_mut(key) {
|
||||
buf.retain(|m| m.msgid != msgid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical CHATHISTORY key for a DM between two nicks (order-independent; the
|
||||
/// `\0` prefix keeps it from ever colliding with a `#channel` key).
|
||||
pub fn dm_key(a: &str, b: &str) -> String {
|
||||
|
|
|
|||
|
|
@ -220,6 +220,34 @@ impl Command for MetadataCmd {
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply a metadata key pushed onto a local user from services (or another server)
|
||||
/// and echo the change to that user if they negotiated the metadata cap. Backs the
|
||||
/// profile keys (avatar/bio/pronouns/timezone/url) that NickServ SET populates, so
|
||||
/// they surface over draft/metadata-2 instead of being dropped on the link.
|
||||
pub fn apply_user(s: &mut Server, uid: Uid, nick: &str, mkey: &str, value: Option<&str>, setter: &str) {
|
||||
let sk = format!("u{uid}");
|
||||
{
|
||||
let st = s.ext.get_or_insert_with::<MetaStore>(MetaStore::default);
|
||||
match value {
|
||||
Some(v) => {
|
||||
st.0.entry(sk).or_default().insert(mkey.to_string(), v.to_string());
|
||||
}
|
||||
None => {
|
||||
if let Some(m) = st.0.get_mut(&sk) {
|
||||
m.remove(mkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.users.get(&uid).map(|u| u.caps.metadata).unwrap_or(false) {
|
||||
let note = match value {
|
||||
Some(v) => format!(":{setter} METADATA {nick} {mkey} * :{v}"),
|
||||
None => format!(":{setter} METADATA {nick} {mkey} *"),
|
||||
};
|
||||
s.send(uid, note);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where channel metadata is persisted (beside the config).
|
||||
fn db_path(s: &Server) -> String {
|
||||
format!("{}.metadata", s.conf_path)
|
||||
|
|
|
|||
|
|
@ -411,6 +411,13 @@ impl Server {
|
|||
for chan in chans {
|
||||
self.join(uid, &chan, None);
|
||||
}
|
||||
// tell services / linked servers this user is now an operator, so services
|
||||
// (OperServ etc.) can track and act on network operators.
|
||||
if !self.links.is_empty() {
|
||||
if let Some(uuid) = self.users.get(&uid).map(|u| u.uuid.clone()) {
|
||||
self.propagate(&format!(":{uuid} OPERTYPE :IRC_Operator"), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a WALLOPS to every oper and every +w user.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue