From 921088bfca4cd675ea7c5a109ddc4aa8610575d1 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 13:57:29 +0000 Subject: [PATCH] chanserv: require channel-operator status to register Registering a channel now needs the caller to actually hold operator status in it, so you can't register a channel you don't control. The network view tracks per-channel ops from FJOIN prefixes and FMODE +o/-o. --- modules/chanserv/chanserv.rs | 5 +++++ modules/protocol/inspircd.rs | 42 ++++++++++++++++++++++++++++++------ modules/protocol/mod.rs | 7 ++++-- src/engine/mod.rs | 27 ++++++++++++++--------- src/engine/state.rs | 30 ++++++++++++++++++++++---- 5 files changed, 88 insertions(+), 23 deletions(-) diff --git a/modules/chanserv/chanserv.rs b/modules/chanserv/chanserv.rs index 2767cdd..1fa4515 100644 --- a/modules/chanserv/chanserv.rs +++ b/modules/chanserv/chanserv.rs @@ -74,6 +74,11 @@ impl Service for ChanServ { ctx.notice(me, from.uid, "You need to be logged in to register a channel. Identify to NickServ first."); return; }; + // You can only register a channel you actually control right now. + if !net.is_op(chan, from.uid) { + ctx.notice(me, from.uid, format!("You must be a channel operator (\x02@\x02) in \x02{chan}\x02 to register it.")); + return; + } match db.register_channel(chan, account) { Ok(()) => { ctx.channel_mode(me, chan, "+r"); // mark the channel registered diff --git a/modules/protocol/inspircd.rs b/modules/protocol/inspircd.rs index ee384ef..d2b4495 100644 --- a/modules/protocol/inspircd.rs +++ b/modules/protocol/inspircd.rs @@ -108,22 +108,23 @@ impl Protocol for InspIrcd { out.push(NetEvent::ChannelKey { channel: chan.to_string(), key }); } } - // Members are ",[:]"; take the uid. + // Members are ",[:]"; the prefixes are the + // status mode letters, so 'o' means they join opped. for m in trailing(rest).split_whitespace() { - if let Some((_, member)) = m.split_once(',') { + if let Some((prefix, member)) = m.split_once(',') { let uid = member.split(':').next().unwrap_or(""); if !uid.is_empty() { - out.push(NetEvent::Join { uid: uid.to_string(), channel: chan.to_string() }); + out.push(NetEvent::Join { uid: uid.to_string(), channel: chan.to_string(), op: prefix.contains('o') }); } } } out } } - // : IJOIN — a single user joining an existing channel. + // : IJOIN — a single user joining an existing channel (not opped). "IJOIN" => match (source.as_deref(), tokens.next()) { (Some(uid), Some(chan)) if chan.starts_with('#') => { - vec![NetEvent::Join { uid: uid.to_string(), channel: chan.to_string() }] + vec![NetEvent::Join { uid: uid.to_string(), channel: chan.to_string(), op: false }] } _ => vec![], }, @@ -151,10 +152,14 @@ impl Protocol for InspIrcd { match (source.as_deref(), a.first(), a.get(2)) { // 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('#') => { + let params = a.get(3..).unwrap_or(&[]); let mut out = vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }]; - if let Some(key) = scan_key(modes, a.get(3..).unwrap_or(&[])) { + if let Some(key) = scan_key(modes, params) { out.push(NetEvent::ChannelKey { channel: chan.to_string(), key }); } + for (op, uid) in scan_ops(modes, params) { + out.push(NetEvent::ChannelOp { channel: chan.to_string(), uid, op }); + } out } _ => vec![], @@ -317,6 +322,29 @@ fn scan_key(modes: &str, params: &[&str]) -> Option> { result } +// Walk a mode change and its params for op grants/revokes (+o/-o). Returns +// (is_op, uid) per `o` in the change, using the same param cursor as scan_key. +fn scan_ops(modes: &str, params: &[&str]) -> Vec<(bool, String)> { + let mut adding = true; + let mut pi = 0; + let mut out = Vec::new(); + for m in modes.chars() { + match m { + '+' => adding = true, + '-' => adding = false, + 'o' => { + if let Some(p) = params.get(pi) { + out.push((adding, p.to_string())); + } + pi += 1; + } + c if takes_param(c, adding) => pi += 1, + _ => {} + } + } + out +} + // The IRC "trailing" argument: everything after the first " :", else the last word. fn trailing(rest: &str) -> String { match rest.find(" :") { @@ -387,6 +415,6 @@ mod tests { assert_eq!(joins, ["0IRAAAAAB", "0IRAAAAAC"]); let ev = proto().parse(":0IRAAAAAD IJOIN #chan"); - assert!(matches!(ev.as_slice(), [NetEvent::Join { uid, channel }] if uid == "0IRAAAAAD" && channel == "#chan"), "{ev:?}"); + assert!(matches!(ev.as_slice(), [NetEvent::Join { uid, channel, op: false }] if uid == "0IRAAAAAD" && channel == "#chan"), "{ev:?}"); } } diff --git a/modules/protocol/mod.rs b/modules/protocol/mod.rs index 4be4cce..ed294a3 100644 --- a/modules/protocol/mod.rs +++ b/modules/protocol/mod.rs @@ -15,10 +15,13 @@ 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 user joined a channel (an FJOIN member or an IJOIN), for auto-op. - Join { uid: String, channel: String }, + // A user joined a channel (an FJOIN member or an IJOIN), for auto-op. `op` is + // whether they hold channel-operator status at that point (an FJOIN prefix). + Join { uid: String, channel: String, op: bool }, // A user left a channel (PART) or was removed (KICK), for membership tracking. Part { uid: String, channel: String }, + // A user's channel-operator status changed (FMODE +o/-o), for live op tracking. + ChannelOp { channel: String, uid: String, op: bool }, // 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 }, diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 31fc1d3..2cf0175 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -275,8 +275,8 @@ impl Engine { } // On join: record membership, enforce the auto-kick list, give access // members their status mode, and send the entry message. - NetEvent::Join { uid, channel } => { - self.network.channel_join(&channel, &uid); + NetEvent::Join { uid, channel, op } => { + self.network.channel_join(&channel, &uid, op); let account = self.network.account_of(&uid).map(str::to_string); let from = self.chan_service.clone().unwrap_or_default(); let mode = account.as_deref().and_then(|a| self.db.channel(&channel).and_then(|c| c.join_mode(a))); @@ -319,6 +319,10 @@ impl Engine { self.network.channel_part(&channel, &uid); Vec::new() } + NetEvent::ChannelOp { channel, uid, op } => { + self.network.set_op(&channel, &uid, op); + Vec::new() + } NetEvent::ChannelKey { channel, key } => { self.network.set_channel_key(&channel, key); Vec::new() @@ -1149,8 +1153,10 @@ mod tests { // Not identified yet: refused. assert!(notice(&to_cs(&mut e, "000AAAAAB", "REGISTER #room"), "logged in")); - // Identify, then register: sets +r on the channel. + // Identify, then register. Registration needs operator status in the channel. e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + assert!(notice(&to_cs(&mut e, "000AAAAAB", "REGISTER #room"), "channel operator"), "op required to register"); + e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: true }); 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:?}"); @@ -1259,12 +1265,12 @@ mod tests { // AKICK a mask, then a matching join is banned and kicked. assert!(to_cs(&mut e, "000AAAAAB", "AKICK #c ADD *!*@bad.host begone").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Added")))); - let out = e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#c".into() }); + let out = e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#c".into(), op: false }); assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#c" && modes == "+b *!*@bad.host")), "{out:?}"); assert!(out.iter().any(|a| matches!(a, NetAction::Kick { channel, uid, reason, .. } if channel == "#c" && uid == "000AAAAAC" && reason == "begone")), "{out:?}"); // The founder joining is never auto-kicked (they get +o instead). - let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into() }); + let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false }); assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("+o"))), "{out:?}"); } @@ -1329,7 +1335,7 @@ mod tests { // ENTRYMSG: set it, then a joining user is noticed it. assert!(notice(&to_cs(&mut e, "000AAAAAB", "ENTRYMSG #c welcome aboard"), "updated")); e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "guest".into(), host: "h2".into() }); - let out = e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#c".into() }); + let out = e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#c".into(), op: false }); assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAC" && text == "welcome aboard")), "{out:?}"); // XOP: AOP add shows in the AOP list and grants op access. @@ -1337,7 +1343,7 @@ mod tests { assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #c LIST"), "carol")); // ENFORCE: alice present gets +o, the akick-matching guest is kicked. - e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into() }); + e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false }); to_cs(&mut e, "000AAAAAB", "AKICK #c ADD *!*@h2 out"); let out = to_cs(&mut e, "000AAAAAB", "ENFORCE #c"); assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+o 000AAAAAB")), "{out:?}"); @@ -1353,6 +1359,7 @@ mod tests { assert!(notice(&to_cs(&mut e, "000AAAAAB", "SEEN guest"), "last seen")); // CLONE: copy #c's settings (incl. the AOP) into a second owned channel. + e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#d".into(), op: true }); e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "REGISTER #d".into() }); assert!(notice(&to_cs(&mut e, "000AAAAAB", "CLONE #c #d"), "Copied")); assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #d LIST"), "carol")); @@ -1412,11 +1419,11 @@ mod tests { e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "host.example".into() }); 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() }); + let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#x".into(), op: false }); 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(), host: "host.example".into() }); - assert!(e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#x".into() }).is_empty(), "non-founder not opped"); + assert!(e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#x".into(), op: false }).is_empty(), "non-founder not opped"); } // An access-list voice gets +v on join. @@ -1434,7 +1441,7 @@ mod tests { e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "carol".into(), host: "host.example".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); - let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#y".into() }); + let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#y".into(), op: false }); assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#y" && modes == "+v 000AAAAAB")), "{out:?}"); } } diff --git a/src/engine/state.rs b/src/engine/state.rs index 0daaf3a..d0ac4de 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -17,10 +17,11 @@ pub struct User { pub host: String, } -// A channel's live membership and current key (+k), tracked from the burst. +// A channel's live membership, ops, and current key (+k), tracked from the burst. #[derive(Default)] pub struct Channel { pub members: HashSet, // uids + pub ops: HashSet, // uids holding channel-operator status pub key: Option, } @@ -68,6 +69,7 @@ impl Network { self.accounts.remove(uid); for c in self.channels.values_mut() { c.members.remove(uid); + c.ops.remove(uid); } } @@ -98,9 +100,13 @@ impl Network { self.accounts.remove(uid); } - // A user joined a channel: record membership and last-seen. - pub fn channel_join(&mut self, channel: &str, uid: &str) { - self.channels.entry(lc(channel)).or_default().members.insert(uid.to_string()); + // A user joined a channel: record membership (and op status) and last-seen. + pub fn channel_join(&mut self, channel: &str, uid: &str, op: bool) { + let c = self.channels.entry(lc(channel)).or_default(); + c.members.insert(uid.to_string()); + if op { + c.ops.insert(uid.to_string()); + } if let Some(nick) = self.nick_of(uid) { self.seen.insert(lc(nick), Seen { nick: nick.to_string(), ts: now(), what: format!("joining {channel}") }); } @@ -110,12 +116,28 @@ impl Network { pub fn channel_part(&mut self, channel: &str, uid: &str) { if let Some(c) = self.channels.get_mut(&lc(channel)) { c.members.remove(uid); + c.ops.remove(uid); } if let Some(nick) = self.nick_of(uid) { self.seen.insert(lc(nick), Seen { nick: nick.to_string(), ts: now(), what: format!("leaving {channel}") }); } } + // Set or clear a user's channel-operator status (FMODE +o/-o). + pub fn set_op(&mut self, channel: &str, uid: &str, op: bool) { + let c = self.channels.entry(lc(channel)).or_default(); + if op { + c.ops.insert(uid.to_string()); + } else { + c.ops.remove(uid); + } + } + + // Whether `uid` currently holds operator status in `channel`. + pub fn is_op(&self, channel: &str, uid: &str) -> bool { + self.channels.get(&lc(channel)).is_some_and(|c| c.ops.contains(uid)) + } + // Uids currently in `channel`. pub fn channel_members(&self, channel: &str) -> impl Iterator { self.channels.get(&lc(channel)).into_iter().flat_map(|c| c.members.iter().map(String::as_str))