BotServ: NOBOT and PRIVATE assignment protections

SET <#channel> NOBOT <on|off> reserves (un)assigning a bot for services
operators (the founder is refused). SET <bot> PRIVATE <on|off> (admin
only) marks a bot operators-only to assign; it's flagged in BOT LIST and
the private flag survives BOT CHANGE. Both gates live in the ASSIGN path.
This commit is contained in:
Jean Chevronnet 2026-07-13 17:36:34 +00:00
parent 1cc438f4ed
commit 5bc41f2e60
No known key found for this signature in database
5 changed files with 92 additions and 13 deletions

View file

@ -342,6 +342,9 @@ pub struct BotView {
pub user: String, pub user: String,
pub host: String, pub host: String,
pub gecos: String, pub gecos: String,
// A private bot may only be assigned by a services admin and is hidden from
// BOT LIST for everyone else.
pub private: bool,
} }
// A services suspension on an account: who, why, when, and an optional expiry // A services suspension on an account: who, why, when, and an optional expiry
@ -388,6 +391,8 @@ pub struct ChannelView {
pub assigned_bot: Option<String>, pub assigned_bot: Option<String>,
// BotServ: whether members' personal greets are shown on join. // BotServ: whether members' personal greets are shown on join.
pub bot_greet: bool, pub bot_greet: bool,
// BotServ: whether the founder is barred from (un)assigning a bot.
pub nobot: bool,
} }
// A single ChanServ SET option, named for the typed `set_channel_setting` call. // A single ChanServ SET option, named for the typed `set_channel_setting` call.
@ -401,6 +406,8 @@ pub enum ChanSetting {
TopicLock, TopicLock,
// BotServ: show members' personal greets on join. // BotServ: show members' personal greets on join.
BotGreet, BotGreet,
// BotServ: forbid the founder from (un)assigning a bot (admin override only).
NoBot,
} }
// A BotServ kicker: the assigned bot kicks a message that trips an enabled one. // A BotServ kicker: the assigned bot kicks a message that trips an enabled one.
@ -571,6 +578,7 @@ pub trait Store {
// BotServ registry (oper-only at the command layer). // BotServ registry (oper-only at the command layer).
fn bot_add(&mut self, nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError>; fn bot_add(&mut self, nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError>;
fn bot_change(&mut self, old: &str, new_nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError>; fn bot_change(&mut self, old: &str, new_nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError>;
fn bot_set_private(&mut self, nick: &str, private: bool) -> Result<bool, ChanError>;
fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError>; fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError>;
fn bots(&self) -> Vec<BotView>; fn bots(&self) -> Vec<BotView>;
fn assign_bot(&mut self, channel: &str, bot: &str) -> Result<(), ChanError>; fn assign_bot(&mut self, channel: &str, bot: &str) -> Result<(), ChanError>;

View file

@ -1,4 +1,4 @@
use fedserv_api::{Sender, ServiceCtx, Store}; use fedserv_api::{Priv, Sender, ServiceCtx, Store};
// ASSIGN <#channel> <bot> / UNASSIGN <#channel>: put a bot in a channel (or take // ASSIGN <#channel> <bot> / UNASSIGN <#channel>: put a bot in a channel (or take
// it out). Channel founder only (or a services admin). // it out). Channel founder only (or a services admin).
@ -11,6 +11,12 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
if !super::require_channel_admin(me, from, chan, ctx, db) { if !super::require_channel_admin(me, from, chan, ctx, db) {
return; return;
} }
// NOBOT reserves (un)assignment for services operators.
let is_admin = from.privs.has(Priv::Admin);
if !is_admin && db.channel(chan).is_some_and(|c| c.nobot) {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 is set \x02NOBOT\x02 — only a services operator can change its bot."));
return;
}
if !assigning { if !assigning {
match db.unassign_bot(chan) { match db.unassign_bot(chan) {
Ok(true) => ctx.notice(me, from.uid, format!("The bot has left \x02{chan}\x02.")), Ok(true) => ctx.notice(me, from.uid, format!("The bot has left \x02{chan}\x02.")),
@ -23,10 +29,15 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
ctx.notice(me, from.uid, "Syntax: ASSIGN <#channel> <bot>"); ctx.notice(me, from.uid, "Syntax: ASSIGN <#channel> <bot>");
return; return;
}; };
let Some(botnick) = db.bots().into_iter().find(|b| b.nick.eq_ignore_ascii_case(bot)).map(|b| b.nick) else { let Some(target) = db.bots().into_iter().find(|b| b.nick.eq_ignore_ascii_case(bot)) else {
ctx.notice(me, from.uid, format!("There's no bot named \x02{bot}\x02. See \x02BOT LIST\x02.")); ctx.notice(me, from.uid, format!("There's no bot named \x02{bot}\x02. See \x02BOT LIST\x02."));
return; return;
}; };
if target.private && !is_admin {
ctx.notice(me, from.uid, format!("Bot \x02{}\x02 is private — only a services operator can assign it.", target.nick));
return;
}
let botnick = target.nick;
match db.assign_bot(chan, &botnick) { match db.assign_bot(chan, &botnick) {
Ok(()) => ctx.notice(me, from.uid, format!("Bot \x02{botnick}\x02 is now assigned to \x02{chan}\x02.")), Ok(()) => ctx.notice(me, from.uid, format!("Bot \x02{botnick}\x02 is now assigned to \x02{chan}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),

View file

@ -57,7 +57,8 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
} }
ctx.notice(me, from.uid, format!("Bots ({}):", bots.len())); ctx.notice(me, from.uid, format!("Bots ({}):", bots.len()));
for b in &bots { for b in &bots {
ctx.notice(me, from.uid, format!(" \x02{}\x02 ({}@{}) — {}", b.nick, b.user, b.host, b.gecos)); let flag = if b.private { " \x02[private]\x02" } else { "" };
ctx.notice(me, from.uid, format!(" \x02{}\x02 ({}@{}) — {}{flag}", b.nick, b.user, b.host, b.gecos));
} }
} }
Some(other) => ctx.notice(me, from.uid, format!("Unknown BOT command \x02{other}\x02. Use \x02ADD\x02, \x02CHANGE\x02, \x02DEL\x02 or \x02LIST\x02.")), Some(other) => ctx.notice(me, from.uid, format!("Unknown BOT command \x02{other}\x02. Use \x02ADD\x02, \x02CHANGE\x02, \x02DEL\x02 or \x02LIST\x02.")),

View file

@ -1,13 +1,43 @@
use fedserv_api::{parse_duration, ChanSetting, Sender, ServiceCtx, Store}; use fedserv_api::{parse_duration, ChanSetting, Priv, Sender, ServiceCtx, Store};
// SET <#channel> <option> <value>: per-channel bot options. Founder-or-admin. // SET <#channel> <option> <value>: per-channel bot options (founder-or-admin) —
// GREET <on|off> (show members' greets on join), BANEXPIRE <duration|off> (how // GREET <on|off>, BANEXPIRE <duration|off>, NOBOT <on|off>. Also
// long kicker bans last). // SET <bot> PRIVATE <on|off> (services-admin only).
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let (Some(&chan), Some(option)) = (args.get(1), args.get(2)) else { let (Some(&target), Some(option)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: SET <#channel> <GREET <ON|OFF> | BANEXPIRE <duration|off>>"); ctx.notice(me, from.uid, "Syntax: SET <#channel> <GREET|BANEXPIRE|NOBOT> <value>, or SET <bot> PRIVATE <ON|OFF>");
return; return;
}; };
// A non-channel target names a bot: the only per-bot option is PRIVATE, and
// it is services-admin only.
if !target.starts_with('#') {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — managing bots is for services operators.");
return;
}
let on = match (option.eq_ignore_ascii_case("PRIVATE"), args.get(3).map(|s| s.to_ascii_uppercase()).as_deref()) {
(true, Some("ON")) => true,
(true, Some("OFF")) => false,
(true, _) => {
ctx.notice(me, from.uid, "Syntax: SET <bot> PRIVATE <ON|OFF>");
return;
}
(false, _) => {
ctx.notice(me, from.uid, format!("Unknown bot option \x02{option}\x02. Available: \x02PRIVATE\x02."));
return;
}
};
match db.bot_set_private(target, on) {
Ok(true) if on => ctx.notice(me, from.uid, format!("Bot \x02{target}\x02 is now private (operators only).")),
Ok(true) => ctx.notice(me, from.uid, format!("Bot \x02{target}\x02 is now public.")),
Ok(false) => ctx.notice(me, from.uid, format!("There's no bot named \x02{target}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
return;
}
let chan = target;
if !super::require_channel_admin(me, from, chan, ctx, db) { if !super::require_channel_admin(me, from, chan, ctx, db) {
return; return;
} }
@ -51,6 +81,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
Ok(()) => ctx.notice(me, from.uid, format!("Greet messages are now \x02off\x02 in \x02{chan}\x02.")), Ok(()) => ctx.notice(me, from.uid, format!("Greet messages are now \x02off\x02 in \x02{chan}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}, },
other => ctx.notice(me, from.uid, format!("Unknown option \x02{other}\x02. Available: \x02GREET\x02, \x02BANEXPIRE\x02.")), "NOBOT" => match db.set_channel_setting(chan, ChanSetting::NoBot, on) {
Ok(()) if on => ctx.notice(me, from.uid, format!("Only operators may (un)assign a bot in \x02{chan}\x02 now.")),
Ok(()) => ctx.notice(me, from.uid, format!("The founder may (un)assign a bot in \x02{chan}\x02 again.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
},
other => ctx.notice(me, from.uid, format!("Unknown option \x02{other}\x02. Available: \x02GREET\x02, \x02BANEXPIRE\x02, \x02NOBOT\x02.")),
} }
} }

View file

@ -209,6 +209,9 @@ pub struct Bot {
pub user: String, pub user: String,
pub host: String, pub host: String,
pub gecos: String, pub gecos: String,
// Oper-only to assign, and hidden from BOT LIST for non-admins.
#[serde(default)]
pub private: bool,
} }
// An auto-join entry: a channel this account is joined to on identify, with an // An auto-join entry: a channel this account is joined to on identify, with an
@ -245,6 +248,9 @@ pub struct ChanSettings {
// BotServ: show members' personal greets when they join. // BotServ: show members' personal greets when they join.
#[serde(default)] #[serde(default)]
pub bot_greet: bool, pub bot_greet: bool,
// BotServ: forbid the founder from (un)assigning a bot (admin override only).
#[serde(default)]
pub nobot: bool,
} }
// A registered channel and who owns it. // A registered channel and who owns it.
@ -1403,6 +1409,7 @@ impl Db {
ChanSetting::KeepTopic => settings.keeptopic = on, ChanSetting::KeepTopic => settings.keeptopic = on,
ChanSetting::TopicLock => settings.topiclock = on, ChanSetting::TopicLock => settings.topiclock = on,
ChanSetting::BotGreet => settings.bot_greet = on, ChanSetting::BotGreet => settings.bot_greet = on,
ChanSetting::NoBot => settings.nobot = on,
} }
self.log self.log
.append(Event::ChannelSettingsSet { channel: channel.to_string(), settings }) .append(Event::ChannelSettingsSet { channel: channel.to_string(), settings })
@ -1586,12 +1593,23 @@ impl Db {
if self.bots.contains_key(&k) { if self.bots.contains_key(&k) {
return Err(ChanError::Exists); return Err(ChanError::Exists);
} }
let bot = Bot { nick: nick.to_string(), user: user.to_string(), host: host.to_string(), gecos: gecos.to_string() }; let bot = Bot { nick: nick.to_string(), user: user.to_string(), host: host.to_string(), gecos: gecos.to_string(), private: false };
self.log.append(Event::BotAdded(bot.clone())).map_err(|_| ChanError::Internal)?; self.log.append(Event::BotAdded(bot.clone())).map_err(|_| ChanError::Internal)?;
self.bots.insert(k, bot); self.bots.insert(k, bot);
Ok(()) Ok(())
} }
/// Mark a bot private (oper-only assign) or public. Returns Ok(false) if
/// there is no such bot.
pub fn bot_set_private(&mut self, nick: &str, private: bool) -> Result<bool, ChanError> {
let k = key(nick);
let Some(mut bot) = self.bots.get(&k).cloned() else { return Ok(false) };
bot.private = private;
self.log.append(Event::BotAdded(bot.clone())).map_err(|_| ChanError::Internal)?;
self.bots.insert(k, bot);
Ok(true)
}
/// Delete a service bot. Returns whether it existed. /// Delete a service bot. Returns whether it existed.
pub fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError> { pub fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError> {
let k = key(nick); let k = key(nick);
@ -1616,7 +1634,9 @@ impl Db {
if renaming && self.bots.contains_key(&nk) { if renaming && self.bots.contains_key(&nk) {
return Err(ChanError::Exists); return Err(ChanError::Exists);
} }
let bot = Bot { nick: new_nick.to_string(), user: user.to_string(), host: host.to_string(), gecos: gecos.to_string() }; // Keep the private flag across a change.
let private = self.bots.get(&ok).map(|b| b.private).unwrap_or(false);
let bot = Bot { nick: new_nick.to_string(), user: user.to_string(), host: host.to_string(), gecos: gecos.to_string(), private };
if renaming { if renaming {
let chans: Vec<String> = self let chans: Vec<String> = self
.channels .channels
@ -2185,11 +2205,14 @@ impl Store for Db {
fn bot_change(&mut self, old: &str, new_nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError> { fn bot_change(&mut self, old: &str, new_nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError> {
Db::bot_change(self, old, new_nick, user, host, gecos) Db::bot_change(self, old, new_nick, user, host, gecos)
} }
fn bot_set_private(&mut self, nick: &str, private: bool) -> Result<bool, ChanError> {
Db::bot_set_private(self, nick, private)
}
fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError> { fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError> {
Db::bot_del(self, nick) Db::bot_del(self, nick)
} }
fn bots(&self) -> Vec<BotView> { fn bots(&self) -> Vec<BotView> {
Db::bots(self).map(|b| BotView { nick: b.nick.clone(), user: b.user.clone(), host: b.host.clone(), gecos: b.gecos.clone() }).collect() Db::bots(self).map(|b| BotView { nick: b.nick.clone(), user: b.user.clone(), host: b.host.clone(), gecos: b.gecos.clone(), private: b.private }).collect()
} }
fn assign_bot(&mut self, channel: &str, bot: &str) -> Result<(), ChanError> { fn assign_bot(&mut self, channel: &str, bot: &str) -> Result<(), ChanError> {
Db::assign_bot(self, channel, bot) Db::assign_bot(self, channel, bot)
@ -2253,6 +2276,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView {
suspended: c.suspension.as_ref().is_some_and(|s| s.expires.is_none_or(|e| e > now())), suspended: c.suspension.as_ref().is_some_and(|s| s.expires.is_none_or(|e| e > now())),
assigned_bot: c.assigned_bot.clone(), assigned_bot: c.assigned_bot.clone(),
bot_greet: c.settings.bot_greet, bot_greet: c.settings.bot_greet,
nobot: c.settings.nobot,
} }
} }