botserv: op assigned bots on join; statserv: persist counters across restart
Some checks failed
CI / check (push) Failing after 3m48s

Problem 1: an assigned bot joined via IJOIN with no status mode, so it had no
+o. Join it with the 4-param IJOIN form (ts 1, mode o) so it's opped, matching
Anope's default bot modes (+o), and exempt bots from SECUREOPS/RESTRICTED so the
op is never stripped.

Problem 2: the shared stat counters lived only in memory, so a restart wiped
them. Snapshot them to the log (StatsSet event, folded into NetData + compaction)
periodically and on shutdown, and reseed the live registry from the log at
startup. Live gauges (online counts) stay re-derived, not stored.
This commit is contained in:
Jean Chevronnet 2026-07-16 17:32:20 +00:00
parent fd455922a5
commit 8a9b6a37ca
No known key found for this signature in database
10 changed files with 85 additions and 10 deletions

View file

@ -136,6 +136,9 @@ pub enum Event {
// by minting a fake one on `sid`), so Local scope but still persisted.
JupeAdded { name: String, sid: String, reason: String },
JupeRemoved { name: String },
// A full snapshot of the shared stat counters (StatServ / gRPC Stats),
// written periodically and on shutdown so they survive a restart.
StatsSet { counters: Vec<(String, u64)> },
}
// Whether an event replicates across the federation. Account identity is Global
@ -238,7 +241,8 @@ impl Event {
| Event::ChannelExpiryWarned { .. }
| Event::ChannelOperNoteSet { .. }
| Event::JupeAdded { .. }
| Event::JupeRemoved { .. } => Scope::Local,
| Event::JupeRemoved { .. }
| Event::StatsSet { .. } => Scope::Local,
}
}
}
@ -623,6 +627,10 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
Event::JupeRemoved { name } => {
net.jupes.retain(|j| !j.name.eq_ignore_ascii_case(&name));
}
Event::StatsSet { counters } => {
// A full snapshot: replace, so replaying the newest wins.
net.stats = counters.into_iter().collect();
}
Event::AccountExpiryWarned { account } => {
if let Some(a) = accounts.get_mut(&key(&account)) {
a.expiry_warned = true;

View file

@ -307,6 +307,9 @@ pub struct NetData {
// The bot auto-assigned to newly registered channels (BotServ AUTOASSIGN),
// if any. Casefolded nick; None disables auto-assignment.
pub default_bot: Option<String>,
// Persisted shared stat counters (StatServ / gRPC Stats), snapshotted so they
// survive a restart (live gauges are re-derived, not stored here).
pub stats: std::collections::BTreeMap<String, u64>,
}
// A session-limit exception: an IP-mask glob and the session allowance for IPs
@ -1165,6 +1168,9 @@ impl Db {
if self.net.default_bot.is_some() {
snapshot.push(Event::DefaultBotSet { bot: self.net.default_bot.clone() });
}
if !self.net.stats.is_empty() {
snapshot.push(Event::StatsSet { counters: self.net.stats.iter().map(|(k, v)| (k.clone(), *v)).collect() });
}
for host in &self.host_cfg.offers {
snapshot.push(Event::VhostOfferAdded { host: host.clone() });
}

View file

@ -245,6 +245,18 @@ impl Db {
self.net.jupes.iter().map(|j| (j.name.clone(), j.sid.clone(), j.reason.clone())).collect()
}
/// The persisted stat counters, to seed the live registry on startup.
pub fn persisted_stats(&self) -> std::collections::BTreeMap<String, u64> {
self.net.stats.clone()
}
/// Snapshot the live stat counters to the log so they survive a restart.
pub fn persist_stats(&mut self, counters: &std::collections::BTreeMap<String, u64>) -> std::io::Result<()> {
self.log.append(Event::StatsSet { counters: counters.iter().map(|(k, v)| (k.clone(), *v)).collect() })?;
self.net.stats = counters.clone();
Ok(())
}
/// File an abuse report, rate-limited per reporter. Returns the new report's
/// id, or None if the reporter filed one too recently.
pub fn report_file(&mut self, reporter: &str, target: &str, reason: &str) -> Option<u64> {

View file

@ -525,6 +525,22 @@
assert_eq!(db.jupes(), vec![("rogue.example".to_string(), sid, "abuse".to_string())], "the jupe replays from the log");
}
// StatServ / Stats-API counters are snapshotted to the log, so they survive a
// restart instead of resetting to zero.
#[test]
fn stat_counters_survive_a_restart() {
let p = tmp("statspersist");
let counters: std::collections::BTreeMap<String, u64> =
[("chanserv.register".to_string(), 7u64), ("nickserv.identify".to_string(), 42u64)].into_iter().collect();
{
let mut db = Db::open(&p, "N1");
db.persist_stats(&counters).unwrap();
assert_eq!(db.persisted_stats(), counters, "stored while live");
}
let db = Db::open(&p, "N1");
assert_eq!(db.persisted_stats(), counters, "counters replay from the log after a restart");
}
// Fold parity: the live state after a broad sequence of writes must equal the
// state rebuilt by replaying the same log — every event's manual live mutation
// has to match its `apply()` arm, or a restart silently changes the data.