diff --git a/Cargo.lock b/Cargo.lock index cd279e3..ff34153 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,6 +327,7 @@ dependencies = [ "base64", "fedserv-api", "fedserv-botserv", + "fedserv-chanfix", "fedserv-chanserv", "fedserv-diceserv", "fedserv-example", @@ -369,6 +370,13 @@ dependencies = [ "fedserv-api", ] +[[package]] +name = "fedserv-chanfix" +version = "0.0.1" +dependencies = [ + "fedserv-api", +] + [[package]] name = "fedserv-chanserv" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 28eb947..d94a06c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv", "reportserv", "groupserv"] +members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv", "reportserv", "groupserv", "chanfix"] [package] name = "fedserv" @@ -22,6 +22,7 @@ fedserv-diceserv = { path = "diceserv" } fedserv-infoserv = { path = "infoserv" } fedserv-reportserv = { path = "reportserv" } fedserv-groupserv = { path = "groupserv" } +fedserv-chanfix = { path = "chanfix" } tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/api/src/lib.rs b/api/src/lib.rs index b4cbd5a..9c3a820 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1027,6 +1027,10 @@ pub trait NetView { // Session limiting: live sessions from an IP, and IPs at/over a threshold. fn session_count(&self, ip: &str) -> u32; fn sessions_over(&self, min: u32) -> Vec<(String, u32)>; + // ChanFix: op-time scores (identity -> score, highest first) and the current + // op count for a channel. + fn chanfix_scores(&self, channel: &str) -> Vec<(String, u32)>; + fn op_count(&self, channel: &str) -> usize; // Search the recent moderation/action incident log (newest first, capped at // `limit`). An empty pattern returns the most recent; otherwise matches the // id or a case-insensitive substring of the summary. diff --git a/chanfix/Cargo.toml b/chanfix/Cargo.toml new file mode 100644 index 0000000..c32b618 --- /dev/null +++ b/chanfix/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "fedserv-chanfix" +version = "0.0.1" +edition = "2021" +description = "ChanFix: reop opless unregistered channels from accumulated op-time scores." + +[dependencies] +fedserv-api = { path = "../api" } diff --git a/chanfix/src/lib.rs b/chanfix/src/lib.rs new file mode 100644 index 0000000..9984e2d --- /dev/null +++ b/chanfix/src/lib.rs @@ -0,0 +1,110 @@ +//! ChanFix helps recover an opless channel. The engine continuously scores +//! op-time per identity from live network state; ChanFix reads those scores to +//! reop the channel's trusted regulars. It **defers to ChanServ**: a registered +//! channel is ChanServ's job, so ChanFix refuses to touch one. Operator-only. +//! +//! SCORES <#channel> shows the standings; CHANFIX <#channel> performs the fix. + +use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store}; + +// A channel with this many ops isn't opless and needs no fix. +const OP_THRESHOLD: usize = 3; +// The top score must reach this before a channel has enough history to fix. +const MIN_FIX_SCORE: u32 = 12; +// Reop identities scoring at least this fraction of the top score. +const FIX_STEP: f64 = 0.30; + +pub struct ChanFix { + pub uid: String, +} + +impl Service for ChanFix { + fn nick(&self) -> &str { + "ChanFix" + } + fn uid(&self) -> &str { + &self.uid + } + fn gecos(&self) -> &str { + "Channel Fixer" + } + + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { + let me = self.uid.as_str(); + // The whole service is operator-only. + if !from.privs.any() { + ctx.notice(me, from.uid, "Access denied — ChanFix is for services operators."); + return; + } + match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { + Some("SCORES") => scores(me, from, args.get(1).copied(), ctx, net), + Some("CHANFIX") | Some("FIX") => fix(me, from, args.get(1).copied(), ctx, net, db), + Some("HELP") | None => help(me, from, ctx), + Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02SCORES\x02 or \x02CHANFIX\x02 <#channel>.")), + } + } +} + +fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) { + ctx.notice(me, from.uid, "ChanFix recovers opless channels from accumulated op-time. \x02SCORES\x02 <#channel> shows the standings; \x02CHANFIX\x02 <#channel> reops its trusted regulars. It won't touch a ChanServ-registered channel."); +} + +fn scores(me: &str, from: &Sender, chan: Option<&str>, ctx: &mut ServiceCtx, net: &dyn NetView) { + let Some(chan) = chan else { + ctx.notice(me, from.uid, "Syntax: SCORES <#channel>"); + return; + }; + let scores = net.chanfix_scores(chan); + if scores.is_empty() { + ctx.notice(me, from.uid, format!("No op-time recorded for \x02{chan}\x02 yet.")); + return; + } + for (id, score) in scores.iter().take(15) { + ctx.notice(me, from.uid, format!(" \x02{score}\x02 {id}")); + } + ctx.notice(me, from.uid, format!("Top {} of {} scored identit{} for \x02{chan}\x02.", scores.len().min(15), scores.len(), if scores.len() == 1 { "y" } else { "ies" })); +} + +fn fix(me: &str, from: &Sender, chan: Option<&str>, ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { + let Some(chan) = chan else { + ctx.notice(me, from.uid, "Syntax: CHANFIX <#channel>"); + return; + }; + // Defer to ChanServ for anything it owns. + if db.channel(chan).is_some() { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 is registered — use \x02ChanServ\x02 to manage it.")); + return; + } + if net.op_count(chan) >= OP_THRESHOLD { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 already has ops — nothing to fix.")); + return; + } + let scores = net.chanfix_scores(chan); + let highscore = scores.first().map(|(_, s)| *s).unwrap_or(0); + if highscore < MIN_FIX_SCORE { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 doesn't have enough op-time history to fix yet.")); + return; + } + let threshold = ((highscore as f64 * FIX_STEP) as u32).max(1); + + // Reop present members whose identity scores at or above the threshold and who + // aren't already opped. + let members: Vec = net.channel_members(chan); + let mut opped = 0; + for uid in &members { + if net.is_op(chan, uid) { + continue; + } + let id = net.account_of(uid).or_else(|| net.host_of(uid)); + let qualifies = id.is_some_and(|i| scores.iter().any(|(sid, s)| sid.eq_ignore_ascii_case(i) && *s >= threshold)); + if qualifies { + ctx.channel_mode(me, chan, &format!("+o {uid}")); + opped += 1; + } + } + if opped == 0 { + ctx.notice(me, from.uid, format!("None of \x02{chan}\x02's trusted regulars are here right now.")); + } else { + ctx.notice(me, from.uid, format!("Reopped \x02{opped}\x02 trusted regular(s) in \x02{chan}\x02.")); + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 32d8358..f176bea 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -575,6 +575,11 @@ impl Engine { Ok(false) } + // One ChanFix scoring pass over the live network (run periodically). + pub fn chanfix_tick(&mut self) { + self.network.chanfix_sample(); + } + // Drop accounts and channels left inactive past the configured thresholds. // Lazy: computed from stored last-activity stamps on this periodic pass, with // no per-record timer. Opers, accounts with a live session, and occupied @@ -4844,6 +4849,60 @@ mod tests { assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused: {out:?}"); } + // ChanFix: op-time is scored from live state, and CHANFIX reops the trusted + // regulars of an opless, unregistered channel; it defers to ChanServ. + #[test] + fn chanfix_scores_and_fixes_opless_channels() { + use fedserv_chanfix::ChanFix; + let path = std::env::temp_dir().join("fedserv-chanfix.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + for a in ["alice", "bob", "staff"] { + db.register(a, "password1", None).unwrap(); + } + db.register_channel("#owned", "staff").unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(ChanFix { uid: "42SAAAAAM".into() }), + ], + db, + ); + e.set_sid("42S".into()); + let mut opers = std::collections::HashMap::new(); + opers.insert("staff".to_string(), Privs::default().with(fedserv_api::Priv::Auspex)); + e.set_opers(opers); + let cf = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAM".into(), text: t.into() }); + let has = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n))); + let opped = |out: &[NetAction], who: &str| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == &format!("+o {who}"))); + + for (uid, nick) in [("000AAAAAA", "alice"), ("000AAAAAB", "bob"), ("000AAAAAS", "staff")] { + e.handle(NetEvent::UserConnect { uid: uid.into(), nick: nick.into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + } + // alice and bob hold ops in an unregistered #lobby; score their op-time. + e.handle(NetEvent::Join { uid: "000AAAAAA".into(), channel: "#lobby".into(), op: true }); + e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#lobby".into(), op: true }); + for _ in 0..15 { + e.chanfix_tick(); + } + + // SCORES shows the accumulated standings. + assert!(has(&cf(&mut e, "000AAAAAS", "SCORES #lobby"), "alice"), "scores recorded"); + // A non-oper can't use ChanFix at all. + assert!(has(&cf(&mut e, "000AAAAAA", "SCORES #lobby"), "Access denied"), "oper-only"); + + // The channel goes opless; CHANFIX reops the trusted regulars still present. + e.handle(NetEvent::ChannelOp { channel: "#lobby".into(), uid: "000AAAAAA".into(), op: false }); + e.handle(NetEvent::ChannelOp { channel: "#lobby".into(), uid: "000AAAAAB".into(), op: false }); + let out = cf(&mut e, "000AAAAAS", "CHANFIX #lobby"); + assert!(opped(&out, "000AAAAAA") && opped(&out, "000AAAAAB"), "reopped the regulars: {out:?}"); + + // It won't touch a ChanServ-registered channel. + assert!(has(&cf(&mut e, "000AAAAAS", "CHANFIX #owned"), "registered"), "defers to ChanServ"); + } + // GroupServ interconnection: a channel grants access to a !group, and every // group member inherits that channel access (auto-op on join). #[test] diff --git a/src/engine/state.rs b/src/engine/state.rs index 78c7c38..b6d6c33 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -28,8 +28,15 @@ pub struct Network { // Recent moderation/action incidents (bounded ring), for OperServ LOGSEARCH. incidents: VecDeque, incident_seq: u64, + // ChanFix op-time scores: channel (lowercase) -> identity (account or host) -> + // score. Sampled from live op state; ephemeral (node-local, rebuilt over time). + chanfix: HashMap>, } +// ChanFix scoring caps and rates. +const CHANFIX_MAX: u32 = 5000; +const CHANFIX_BUMP: u32 = 2; // per sample while opped (net +1 after the -1 decay) + // One recorded action: a short id (also stamped into the action's reason), the // unix time it happened, and a human summary. struct Incident { @@ -303,6 +310,45 @@ impl Network { self.channels.get(&lc(channel)).into_iter().flat_map(|c| c.members.iter().map(String::as_str)) } + // How many uids currently hold op in `channel`. + pub fn op_count(&self, channel: &str) -> usize { + self.channels.get(&lc(channel)).map_or(0, |c| c.ops.len()) + } + + // One ChanFix sampling pass: every tracked score decays by 1; every currently- + // opped user's identity (its account, else its host) gains CHANFIX_BUMP — a net + // +1 while opped, -1 while idle, so trusted regulars rise and stale ops fade. + pub fn chanfix_sample(&mut self) { + for scores in self.chanfix.values_mut() { + for v in scores.values_mut() { + *v = v.saturating_sub(1); + } + } + let mut samples: Vec<(String, String)> = Vec::new(); + for (chan, c) in &self.channels { + for uid in &c.ops { + if let Some(id) = self.accounts.get(uid).cloned().or_else(|| self.users.get(uid).map(|u| u.host.clone())) { + samples.push((chan.clone(), id)); + } + } + } + for (chan, id) in samples { + let s = self.chanfix.entry(chan).or_default().entry(id).or_insert(0); + *s = (*s + CHANFIX_BUMP).min(CHANFIX_MAX); + } + for scores in self.chanfix.values_mut() { + scores.retain(|_, v| *v > 0); + } + self.chanfix.retain(|_, m| !m.is_empty()); + } + + // A channel's ChanFix scores (identity -> score), highest first. + pub fn chanfix_scores(&self, channel: &str) -> Vec<(String, u32)> { + let mut v: Vec<(String, u32)> = self.chanfix.get(&lc(channel)).into_iter().flat_map(|m| m.iter().map(|(k, &s)| (k.clone(), s))).collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + v + } + pub fn set_channel_key(&mut self, channel: &str, key: Option) { self.channels.entry(lc(channel)).or_default().key = key; } @@ -365,4 +411,10 @@ impl NetView for Network { fn search_incidents(&self, pattern: &str, limit: usize) -> Vec { Network::search_incidents(self, pattern, limit) } + fn chanfix_scores(&self, channel: &str) -> Vec<(String, u32)> { + Network::chanfix_scores(self, channel) + } + fn op_count(&self, channel: &str) -> usize { + Network::op_count(self, channel) + } } diff --git a/src/main.rs b/src/main.rs index 32db4f8..2b90d67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ use fedserv_diceserv::DiceServ; use fedserv_infoserv::InfoServ; use fedserv_reportserv::ReportServ; use fedserv_groupserv::GroupServ; +use fedserv_chanfix::ChanFix; use fedserv_example::ExampleServ; use fedserv_inspircd::InspIrcd; use fedserv_nickserv::NickServ; @@ -113,6 +114,11 @@ async fn main() -> Result<()> { uid: format!("{}AAAAAL", cfg.server.sid), })); } + if enabled("chanfix") { + services.push(Box::new(ChanFix { + uid: format!("{}AAAAAM", cfg.server.sid), + })); + } if enabled("example") { services.push(Box::new(ExampleServ { uid: format!("{}AAAAAC", cfg.server.sid), @@ -176,6 +182,17 @@ async fn main() -> Result<()> { }); } + // ChanFix scores op-time on a shorter cadence so the fix has fresh data. + if enabled("chanfix") { + let engine = engine.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(300)).await; + engine.lock().await.chanfix_tick(); + } + }); + } + let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port); tracing::info!(server = %cfg.server.name, %addr, "linking to uplink"); link::run(proto, engine, &addr, irc_rx, cfg.email.clone()).await