diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 36292af..c3fc130 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -73,16 +73,19 @@ pub struct Engine { db: Db, sasl_sessions: HashMap, // client uid -> in-progress exchange reg_limiter: RegLimiter, + chan_service: Option, // uid to source channel modes from (ChanServ) } impl Engine { pub fn new(services: Vec>, db: Db) -> Self { + let chan_service = services.iter().find(|s| s.manages_channels()).map(|s| s.uid().to_string()); Self { services, network: Network::default(), db, sasl_sessions: HashMap::new(), reg_limiter: RegLimiter::new(), + chan_service, } } @@ -174,15 +177,17 @@ impl Engine { // creation and on the uplink burst, so registered channels regain the // mode after emptying, and after a services relink. NetEvent::ChannelCreate { channel } => { + let from = self.chan_service.clone().unwrap_or_default(); match self.db.channel(&channel) { - Some(info) => vec![NetAction::ChannelMode { channel, modes: info.lock_modes() }], + Some(info) => vec![NetAction::ChannelMode { from, channel, modes: info.lock_modes() }], None => Vec::new(), } } // Enforce the mode lock: revert any change that broke it. NetEvent::ChannelModeChange { channel, modes } => { + let from = self.chan_service.clone().unwrap_or_default(); match self.db.channel(&channel).and_then(|info| info.enforce(&modes)) { - Some(revert) => vec![NetAction::ChannelMode { channel, modes: revert }], + Some(revert) => vec![NetAction::ChannelMode { from, channel, modes: revert }], None => Vec::new(), } } @@ -191,7 +196,10 @@ impl Engine { let founder = self.db.channel(&channel).map(|c| c.founder.clone()); let account = self.network.account_of(&uid).map(str::to_string); match (founder, account) { - (Some(f), Some(a)) if f == a => vec![NetAction::ChannelMode { channel, modes: format!("+o {uid}") }], + (Some(f), Some(a)) if f == a => { + let from = self.chan_service.clone().unwrap_or_default(); + vec![NetAction::ChannelMode { from, channel, modes: format!("+o {uid}") }] + } _ => Vec::new(), } } @@ -951,13 +959,13 @@ mod tests { e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); let out = to_cs(&mut e, "000AAAAAB", "REGISTER #room"); assert!(notice(&out, "now registered")); - assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#room" && modes == "+r")), "register sets +r: {out:?}"); + 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")); // 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!(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. @@ -968,7 +976,7 @@ mod tests { // The founder can, which also clears +r. let out = to_cs(&mut e, "000AAAAAB", "DROP #room"); assert!(notice(&out, "dropped")); - assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#room" && modes == "-r")), "drop clears +r: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#room" && modes == "-r")), "drop clears +r: {out:?}"); } // A registered channel (re)appearing re-asserts +r; an unregistered one does not. @@ -982,7 +990,7 @@ mod tests { let mut e = Engine::new(vec![Box::new(ns)], db); let out = e.handle(NetEvent::ChannelCreate { channel: "#reg".into() }); - assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#reg" && modes == "+r")), "registered channel regains +r: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#reg" && modes == "+r")), "registered channel regains +r: {out:?}"); let out = e.handle(NetEvent::ChannelCreate { channel: "#other".into() }); assert!(out.is_empty(), "unregistered channel is left alone: {out:?}"); @@ -1001,11 +1009,11 @@ mod tests { // 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:?}"); + 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:?}"); + 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()); @@ -1026,7 +1034,7 @@ mod tests { e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#x".into() }); - assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes } if channel == "#x" && modes == "+o 000AAAAAB")), "founder opped: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#x" && modes == "+o 000AAAAAB")), "founder opped: {out:?}"); e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() }); assert!(e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#x".into() }).is_empty(), "non-founder not opped"); diff --git a/src/engine/service.rs b/src/engine/service.rs index 669f52b..6259143 100644 --- a/src/engine/service.rs +++ b/src/engine/service.rs @@ -18,6 +18,11 @@ pub trait Service: Send { "services.local" } fn gecos(&self) -> &str; + // Whether this service owns channel modes (ChanServ), so the engine can source + // channel mode changes from it. + fn manages_channels(&self) -> bool { + false + } fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db); } @@ -75,9 +80,10 @@ impl ServiceCtx { }); } - // Set channel modes from services, e.g. "+r" on a registered channel. - pub fn channel_mode(&mut self, channel: &str, modes: &str) { + // Set channel modes, sourced from pseudoclient `from` (e.g. ChanServ). + pub fn channel_mode(&mut self, from: &str, channel: &str, modes: &str) { self.actions.push(NetAction::ChannelMode { + from: from.to_string(), channel: channel.to_string(), modes: modes.to_string(), }); diff --git a/src/proto/inspircd.rs b/src/proto/inspircd.rs index d273518..04aca96 100644 --- a/src/proto/inspircd.rs +++ b/src/proto/inspircd.rs @@ -122,7 +122,8 @@ impl Protocol for InspIrcd { "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('#') => { + // Our own uids (server SID + pseudoclients) share our SID prefix. + (Some(src), Some(chan), Some(modes)) if !src.starts_with(self.sid.as_str()) && chan.starts_with('#') => { vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }] } _ => vec![], @@ -218,10 +219,16 @@ impl Protocol for InspIrcd { vec![self.from_us(format!("SVSNICK {} {} {}", uid, nick, now))] } // FMODE . The ircd drops an FMODE whose TS is newer - // than the channel's, so we send TS 1 to guarantee it applies to the - // existing channel. Sourced from our server, which +r requires. - NetAction::ChannelMode { channel, modes } => { - vec![self.from_us(format!("FMODE {} 1 {}", channel, modes))] + // than the channel's, so we send TS 1 to guarantee it applies. Sourced + // from the given pseudoclient (e.g. ChanServ) so users see who set it, + // or from the services server when `from` is empty. + NetAction::ChannelMode { from, channel, modes } => { + let cmd = format!("FMODE {} 1 {}", channel, modes); + if from.is_empty() { + vec![self.from_us(cmd)] + } else { + vec![format!(":{} {}", from, cmd)] + } } NetAction::Raw(s) => vec![s.clone()], // Internal: the link layer handles this before serialization. diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 4f7db90..0daa882 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -46,9 +46,10 @@ pub enum NetAction { // Force a user's nick (SVSNICK), e.g. renaming to a guest nick on logout. // The protocol stamps the new nick's timestamp. ForceNick { uid: String, nick: String }, - // Set channel modes from services, e.g. +r on a registered channel. The + // Set channel modes from services, e.g. +r on a registered channel. `from` is + // the pseudoclient uid to source it from (empty = the services server). The // protocol stamps a timestamp the ircd will accept. - ChannelMode { channel: String, modes: String }, + ChannelMode { from: String, channel: String, modes: String }, Raw(String), // Internal only, never serialized to the wire: a registration whose password // still needs its (expensive) key derivation. The link layer runs the diff --git a/src/services/chanserv.rs b/src/services/chanserv.rs index 3b42cdf..be3d3da 100644 --- a/src/services/chanserv.rs +++ b/src/services/chanserv.rs @@ -15,6 +15,9 @@ impl Service for ChanServ { fn gecos(&self) -> &str { "Channel Services" } + fn manages_channels(&self) -> bool { + true + } fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { let me = self.uid.as_str(); @@ -35,7 +38,7 @@ impl Service for ChanServ { }; match db.register_channel(chan, account) { Ok(()) => { - ctx.channel_mode(chan, "+r"); // mark the channel registered + ctx.channel_mode(me, chan, "+r"); // mark the channel registered ctx.notice(me, from.uid, format!("\x02{chan}\x02 is now registered and you are its founder. Enjoy!")); } Err(ChanError::Exists) => ctx.notice(me, from.uid, format!("\x02{chan}\x02 is already registered. Try \x02INFO {chan}\x02 to see who owns it.")), @@ -75,7 +78,7 @@ impl Service for ChanServ { } match db.drop_channel(chan) { Ok(()) => { - ctx.channel_mode(chan, "-r"); // no longer registered + ctx.channel_mode(me, chan, "-r"); // no longer registered ctx.notice(me, from.uid, format!("\x02{chan}\x02 has been dropped and is no longer registered.")); } Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), @@ -113,7 +116,7 @@ impl Service for ChanServ { 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.channel_mode(me, chan, &info.lock_modes()); // apply it now } ctx.notice(me, from.uid, format!("Mode lock for \x02{chan}\x02 updated.")); }