From 353aee1b54a5fa95d8cd4e9764500184d4cb170e Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 09:33:23 +0000 Subject: [PATCH] Add ChanServ mode lock (MLOCK) Founders lock channel modes with MLOCK <#channel> [modes]. The lock is a ChannelMlock event, so it replicates and compacts like everything else; it is applied on channel creation alongside +r and enforced by reverting any FMODE that breaks it. Our own changes are filtered so enforcement can't loop. Simple (paramless) modes for now. --- src/engine/db.rs | 108 ++++++++++++++++++++++++++++++++++++--- src/engine/mod.rs | 46 +++++++++++++++-- src/proto/inspircd.rs | 23 +++++++++ src/proto/mod.rs | 3 ++ src/services/chanserv.rs | 82 ++++++++++++++++++++++++++++- 5 files changed, 248 insertions(+), 14 deletions(-) diff --git a/src/engine/db.rs b/src/engine/db.rs index d403eae..b42dbc6 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -40,6 +40,7 @@ pub enum Event { CertRemoved { account: String, fp: String }, ChannelRegistered { name: String, founder: String, ts: u64 }, ChannelDropped { name: String }, + ChannelMlock { name: String, on: String, off: String }, } // A registered channel and who owns it. @@ -48,6 +49,57 @@ pub struct ChannelInfo { pub name: String, pub founder: String, pub ts: u64, + // Mode-lock: chars services keep set / unset (besides the implicit +r). + #[serde(default)] + pub lock_on: String, + #[serde(default)] + pub lock_off: String, +} + +impl ChannelInfo { + /// The mode string services keep applied: +r plus the lock. + pub fn lock_modes(&self) -> String { + let mut s = format!("+r{}", self.lock_on); + if !self.lock_off.is_empty() { + s.push('-'); + s.push_str(&self.lock_off); + } + s + } + + /// Given a mode change, the modes to send back to restore the lock, if it was + /// violated. +r is always locked on. Only simple (paramless) modes are checked. + pub fn enforce(&self, change: &str) -> Option { + let (mut readd, mut reremove) = (String::new(), String::new()); + let mut adding = true; + for ch in change.chars() { + match ch { + '+' => adding = true, + '-' => adding = false, + m if m.is_ascii_alphabetic() => { + if (m == 'r' || self.lock_on.contains(m)) && !adding && !readd.contains(m) { + readd.push(m); + } else if self.lock_off.contains(m) && adding && !reremove.contains(m) { + reremove.push(m); + } + } + _ => {} + } + } + if readd.is_empty() && reremove.is_empty() { + return None; + } + let mut s = String::new(); + if !readd.is_empty() { + s.push('+'); + s.push_str(&readd); + } + if !reremove.is_empty() { + s.push('-'); + s.push_str(&reremove); + } + Some(s) + } } // A durable record: the event plus the metadata a gossip layer needs to address @@ -288,11 +340,12 @@ impl Db { pub fn compact(&mut self) -> std::io::Result<()> { let before = self.log.len(); let mut snapshot: Vec = self.accounts.values().cloned().map(Event::AccountRegistered).collect(); - snapshot.extend(self.channels.values().map(|c| Event::ChannelRegistered { - name: c.name.clone(), - founder: c.founder.clone(), - ts: c.ts, - })); + for c in self.channels.values() { + snapshot.push(Event::ChannelRegistered { name: c.name.clone(), founder: c.founder.clone(), ts: c.ts }); + if !c.lock_on.is_empty() || !c.lock_off.is_empty() { + snapshot.push(Event::ChannelMlock { name: c.name.clone(), on: c.lock_on.clone(), off: c.lock_off.clone() }); + } + } self.log.compact(snapshot)?; tracing::info!(before, after = self.log.len(), "compacted event log"); Ok(()) @@ -432,7 +485,7 @@ impl Db { self.log .append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts }) .map_err(|_| ChanError::Internal)?; - self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts }); + self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new() }); Ok(()) } @@ -441,6 +494,21 @@ impl Db { self.channels.get(&key(name)) } + /// Set the mode-lock (chars to keep set / unset) for a registered channel. + pub fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError> { + let k = key(name); + if !self.channels.contains_key(&k) { + return Err(ChanError::NoChannel); + } + self.log + .append(Event::ChannelMlock { name: name.to_string(), on: on.to_string(), off: off.to_string() }) + .map_err(|_| ChanError::Internal)?; + let c = self.channels.get_mut(&k).unwrap(); + c.lock_on = on.to_string(); + c.lock_off = off.to_string(); + Ok(()) + } + /// Unregister `name`. pub fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> { let k = key(name); @@ -473,11 +541,17 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { - channels.insert(key(&name), ChannelInfo { name, founder, ts }); + channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new() }); } Event::ChannelDropped { name } => { channels.remove(&key(&name)); } + Event::ChannelMlock { name, on, off } => { + if let Some(c) = channels.get_mut(&key(&name)) { + c.lock_on = on; + c.lock_off = off; + } + } } } @@ -625,4 +699,24 @@ mod tests { assert!(db.channel("#chat").is_none()); assert!(matches!(db.drop_channel("#chat"), Err(ChanError::NoChannel))); } + + // Mode lock persists, renders the applied string, and detects violations. + #[test] + fn mode_lock_persists_and_enforces() { + let p = tmp("mlock"); + let mut db = Db::open(&p, "local"); + db.register_channel("#chan", "alice").unwrap(); + db.set_mlock("#chan", "nt", "s").unwrap(); + + let info = db.channel("#chan").unwrap(); + assert_eq!(info.lock_modes(), "+rnt-s"); + assert_eq!(info.enforce("-nt"), Some("+nt".to_string())); // locked-on removed + assert_eq!(info.enforce("+s"), Some("-s".to_string())); // locked-off added + assert_eq!(info.enforce("-r"), Some("+r".to_string())); // +r always locked + assert_eq!(info.enforce("+m"), None); // unrelated mode ignored + + drop(db); + let db = Db::open(&p, "local"); + assert_eq!(db.channel("#chan").unwrap().lock_modes(), "+rnt-s", "survives reopen"); + } } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index a3fa465..ac363ee 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -174,10 +174,16 @@ impl Engine { // creation and on the uplink burst, so registered channels regain the // mode after emptying, and after a services relink. NetEvent::ChannelCreate { channel } => { - if self.db.channel(&channel).is_some() { - vec![NetAction::ChannelMode { channel, modes: "+r".to_string() }] - } else { - Vec::new() + match self.db.channel(&channel) { + Some(info) => vec![NetAction::ChannelMode { channel, modes: info.lock_modes() }], + None => Vec::new(), + } + } + // Enforce the mode lock: revert any change that broke it. + NetEvent::ChannelModeChange { channel, modes } => { + match self.db.channel(&channel).and_then(|info| info.enforce(&modes)) { + Some(revert) => vec![NetAction::ChannelMode { channel, modes: revert }], + None => Vec::new(), } } NetEvent::Quit { uid } => { @@ -939,8 +945,15 @@ mod tests { assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#room" && modes == "+r")), "register sets +r: {out:?}"); assert!(notice(&to_cs(&mut e, "000AAAAAB", "INFO #room"), "alice")); - // A different, unidentified user cannot drop it. + // Founder sets a mode lock: it applies immediately and shows on view. + let out = to_cs(&mut e, "000AAAAAB", "MLOCK #room +nt-s"); + assert!(notice(&out, "updated")); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#room" && modes == "+rnt-s")), "mlock applied: {out:?}"); + assert!(notice(&to_cs(&mut e, "000AAAAAB", "MLOCK #room"), "+nt-s")); + + // A different, unidentified user cannot set the lock or drop it. e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() }); + assert!(notice(&to_cs(&mut e, "000AAAAAC", "MLOCK #room +i"), "founder")); assert!(notice(&to_cs(&mut e, "000AAAAAC", "DROP #room"), "founder")); // The founder can, which also clears +r. @@ -965,4 +978,27 @@ mod tests { let out = e.handle(NetEvent::ChannelCreate { channel: "#other".into() }); assert!(out.is_empty(), "unregistered channel is left alone: {out:?}"); } + + // A channel's mode lock is applied on creation and enforced on violation. + #[test] + fn mode_lock_is_applied_and_enforced() { + let path = std::env::temp_dir().join("fedserv-mlock-engine.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.register_channel("#lk", "alice").unwrap(); + db.set_mlock("#lk", "nt", "s").unwrap(); + let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; + let mut e = Engine::new(vec![Box::new(ns)], db); + + // Creation applies the full lock. + let out = e.handle(NetEvent::ChannelCreate { channel: "#lk".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#lk" && modes == "+rnt-s")), "{out:?}"); + + // Setting a locked-off mode is reverted. + let out = e.handle(NetEvent::ChannelModeChange { channel: "#lk".into(), modes: "+s".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#lk" && modes == "-s")), "{out:?}"); + + // An unrelated mode change is left alone. + assert!(e.handle(NetEvent::ChannelModeChange { channel: "#lk".into(), modes: "+m".into() }).is_empty()); + } } diff --git a/src/proto/inspircd.rs b/src/proto/inspircd.rs index 2ac3401..5678774 100644 --- a/src/proto/inspircd.rs +++ b/src/proto/inspircd.rs @@ -95,6 +95,17 @@ impl Protocol for InspIrcd { Some(chan) if !chan.is_empty() => vec![NetEvent::ChannelCreate { channel: chan.to_string() }], _ => vec![], }, + // : FMODE [params] — a channel mode change. + // Skip changes we made ourselves so enforcement can't loop. + "FMODE" => { + let a: Vec<&str> = tokens.collect(); + match (source.as_deref(), a.first(), a.get(2)) { + (Some(src), Some(chan), Some(modes)) if src != self.sid && chan.starts_with('#') => { + vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }] + } + _ => vec![], + } + } "QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }], // account-registration relay from an ircd: // ACCTREGISTER : @@ -247,4 +258,16 @@ mod tests { "{ev:?}" ); } + + // A peer FMODE surfaces as a mode change; our own (sid 42S) is filtered out. + #[test] + fn parses_fmode_and_filters_own() { + let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 +m"); + assert!( + matches!(ev.as_slice(), [NetEvent::ChannelModeChange { channel, modes }] if channel == "#chan" && modes == "+m"), + "{ev:?}" + ); + let ev = proto().parse(":42S FMODE #chan 1783845132 -m"); + assert!(!ev.iter().any(|e| matches!(e, NetEvent::ChannelModeChange { .. })), "own change filtered: {ev:?}"); + } } diff --git a/src/proto/mod.rs b/src/proto/mod.rs index adbdbdc..a640c1f 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -15,6 +15,9 @@ pub enum NetEvent { // A channel was created or bursted (InspIRCd FJOIN). Subsequent single joins // arrive as IJOIN and are not surfaced. ChannelCreate { channel: String }, + // A channel's modes changed (FMODE), for enforcing mode locks. Our own + // changes are filtered out by the protocol layer. + ChannelModeChange { channel: String, modes: String }, Quit { uid: String }, // An ircd relaying an IRCv3 account-registration request to us as the authority. AccountRequest { reqid: String, origin: String, kind: String, account: String, p2: String, p3: String }, diff --git a/src/services/chanserv.rs b/src/services/chanserv.rs index 70ce663..3b42cdf 100644 --- a/src/services/chanserv.rs +++ b/src/services/chanserv.rs @@ -1,4 +1,4 @@ -use crate::engine::db::{ChanError, Db}; +use crate::engine::db::{ChanError, ChannelInfo, Db}; use crate::engine::service::{Sender, Service, ServiceCtx}; pub struct ChanServ { @@ -81,13 +81,91 @@ impl Service for ChanServ { Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } } - Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02 <#channel>, \x02INFO\x02 <#channel>, \x02DROP\x02 <#channel>."), + Some("MLOCK") => { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: MLOCK <#channel> [modes], e.g. MLOCK #chan +nt-s"); + return; + }; + // No modes given: show the current lock. + if args.len() <= 2 { + match db.channel(chan) { + None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")), + Some(info) if info.lock_on.is_empty() && info.lock_off.is_empty() => { + ctx.notice(me, from.uid, format!("\x02{}\x02 has no mode lock set.", info.name)); + } + Some(info) => ctx.notice(me, from.uid, format!("Mode lock for \x02{}\x02: \x02{}\x02", info.name, show_mlock(info))), + } + return; + } + // Setting the lock: founder only. + let founder = match db.channel(chan) { + Some(info) => info.founder.clone(), + None => { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); + return; + } + }; + if from.account != Some(founder.as_str()) { + ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can set its mode lock.")); + return; + } + let (on, off) = parse_mlock(&args[2..].concat()); + match db.set_mlock(chan, &on, &off) { + Ok(()) => { + if let Some(info) = db.channel(chan) { + ctx.channel_mode(chan, &info.lock_modes()); // apply it now + } + ctx.notice(me, from.uid, format!("Mode lock for \x02{chan}\x02 updated.")); + } + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02 <#channel>, \x02INFO\x02 <#channel>, \x02MLOCK\x02 <#channel> [modes], \x02DROP\x02 <#channel>."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), None => {} } } } +// Parse a mode spec like "+nt-s" into (locked-on, locked-off) mode chars. `r` is +// implicit for a registered channel and is ignored here. +fn parse_mlock(spec: &str) -> (String, String) { + let (mut on, mut off) = (String::new(), String::new()); + let mut adding = true; + for ch in spec.chars() { + match ch { + '+' => adding = true, + '-' => adding = false, + 'r' => {} + m if m.is_ascii_alphabetic() => { + on.retain(|c| c != m); + off.retain(|c| c != m); + if adding { + on.push(m); + } else { + off.push(m); + } + } + _ => {} + } + } + (on, off) +} + +// Render a channel's lock as "+on-off" for display. +fn show_mlock(info: &ChannelInfo) -> String { + let mut s = String::new(); + if !info.lock_on.is_empty() { + s.push('+'); + s.push_str(&info.lock_on); + } + if !info.lock_off.is_empty() { + s.push('-'); + s.push_str(&info.lock_off); + } + s +} + // Format a Unix timestamp (seconds) as "YYYY-MM-DD HH:MM:SS UTC", using Howard // Hinnant's civil-from-days algorithm so no date crate is needed. fn human_time(ts: u64) -> String {