diff --git a/echoircd.conf.example b/echoircd.conf.example index b1ed3de..1c0b6b1 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -235,6 +235,10 @@ amu_target = both # reputationexpire = * 90d # any score decays after 90d # extban usage: MODE #chan +b y:<100 (ban score below 100) +b y:>500 (above 500) +# --- permanent channels (+P): oper-only mode; the channel survives an empty +# member list AND a restart (its modes, topic, TS and ban lists are saved). --- +# permchannels_database = permchannels.db # default: .permchannels + # --- whoisport: opers see the target's listener port in WHOIS (always on) --- # --- ircv3_network_icon: advertise a network icon via draft/ICON ISUPPORT --- # network_icon = https://example.org/icon.png diff --git a/src/ircd.rs b/src/ircd.rs index 9f9aaac..37fc455 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -131,6 +131,7 @@ impl Ircd { server.load_xlines(); // restore persisted bans crate::modules::metadata::load(&mut server); // restore channel metadata crate::modules::reputation::load(&mut server); // restore per-IP reputation + crate::modules::permchannels::load(&mut server); // recreate +P channels (pre-link) crate::modules::geoip::init(&mut server); // load the GeoIP database crate::modules::customprefix::init(&server); // load prefix config crate::mode::init_custom_prefixes(); // register any config-defined prefix modes diff --git a/src/modules/mod.rs b/src/modules/mod.rs index dc3489d..2a07d0b 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -53,6 +53,7 @@ pub mod ojoin; pub mod operlevels; pub mod operprefix; pub mod password_hash; +pub mod permchannels; pub mod profilelink; pub mod randquote; pub mod realnameban; @@ -109,6 +110,7 @@ pub fn default_modules() -> Vec> { Box::new(autoop::AutoOp), Box::new(autodrop::AutoDrop), Box::new(operprefix::OperPrefix), + Box::new(permchannels::PermChannels::default()), ] } diff --git a/src/modules/permchannels.rs b/src/modules/permchannels.rs new file mode 100644 index 0000000..9de7240 --- /dev/null +++ b/src/modules/permchannels.rs @@ -0,0 +1,281 @@ +//! Permanent-channel persistence. A `+P` channel already survives an empty +//! member list in memory (see `Channel::keep_alive`); this module makes it also +//! survive a restart by writing every `+P` channel — its creation TS, modes (with +//! parameters), topic and list modes — to a database file and recreating them at +//! startup, before any server link is established. The file is refreshed whenever +//! a mode/topic change touches a permanent channel (and on the timer as a backstop), +//! with an atomic temp-file+rename write so a crash mid-write can't corrupt it. +//! +//! Config: `permchannels_database` (path; default `.permchannels`). + +use std::fs; +use std::io; + +use crate::channels::{Ban, Channel, Topic}; +use crate::module::Module; +use crate::server::Server; +use crate::Uid; + +/// The database path: the `permchannels_database` conf key, or `.permchannels`. +fn db_path(s: &Server) -> String { + match s.conf("permchannels_database") { + Some(p) if !p.is_empty() => p.to_string(), + _ => format!("{}.permchannels", s.conf_path), + } +} + +/// Serialise every permanent channel. Empty string when there are none, so the +/// caller can drop a stale database instead of leaving resurrectable channels. +/// Channels are emitted name-sorted so an unchanged network yields identical bytes +/// (the dirty check below then skips the write). +fn serialize(s: &Server) -> String { + let mut chans: Vec<&Channel> = s.channels.values().filter(|c| c.modes.permanent).collect(); + if chans.is_empty() { + return String::new(); + } + chans.sort_by(|a, b| a.name.cmp(&b.name)); + + let mut out = String::from("# echoircd permanent channels — auto-generated; manual edits are overwritten\n"); + for c in chans { + out.push_str(&format!("C {} {}\n", c.name, c.created)); + out.push_str(&format!("M {}\n", c.modes.render(true))); + if let Some(t) = &c.topic { + if !t.text.is_empty() { + out.push_str(&format!("T {} {} :{}\n", t.ts, t.setter, t.text)); + } + } + // list modes: mask/setter/ts, skipping timed (TBAN) entries — those are + // ephemeral and shouldn't be resurrected as permanent bans. + for (tag, list) in [ + ('b', &c.bans), + ('e', &c.excepts), + ('I', &c.invex), + ('g', &c.filters), + ('X', &c.exemptchanops), + ('w', &c.autoop), + ] { + for b in list.iter().filter(|b| b.expires.is_none()) { + out.push_str(&format!("{tag} {} {} {}\n", b.ts, b.setter, b.mask)); + } + } + } + out +} + +/// Atomic write: a full temp file then rename over the target, so a reader (or a +/// crash) never sees a half-written database. +fn atomic_write(path: &str, content: &str) -> io::Result<()> { + let tmp = format!("{path}.tmp"); + fs::write(&tmp, content)?; + fs::rename(&tmp, path) +} + +/// One channel record accumulated while parsing the database. +#[derive(Default)] +struct Record { + name: String, + created: u64, + modes: String, + topic: Option, + lists: Vec<(char, Ban)>, +} + +/// Apply a rendered mode string (`+ntPl 50`) to an existing channel as the server, +/// bypassing the oper/rank gates and without propagating (there are no links yet +/// at load time). Mirrors the applier the MODE path uses, minus the side effects. +fn apply_modes(s: &mut Server, name: &str, key: &str, modes: &str) { + let mut it = modes.split_whitespace(); + let Some(letters) = it.next() else { + return; + }; + let args: Vec<&str> = it.collect(); + let mut ai = 0usize; + let mut adding = true; + s.mode_sudo = true; + for c in letters.chars() { + match c { + '+' => { + adding = true; + continue; + } + '-' => { + adding = false; + continue; + } + _ => {} + } + let Some(h) = crate::mode::chan_mode(c) else { + continue; + }; + let param = if h.wants_param(adding) { + let p = args.get(ai).copied(); + if p.is_some() { + ai += 1; + } + p + } else { + None + }; + let _ = h.apply(s, name, key, 0, adding, param); + } + s.mode_sudo = false; +} + +/// Recreate one parsed record as a live (member-less) channel. +fn build(s: &mut Server, rec: Record) { + if rec.name.is_empty() { + return; + } + let key = rec.name.to_ascii_lowercase(); + if s.channels.contains_key(&key) { + return; // never clobber a channel that already exists + } + let mut c = Channel::new(&rec.name); + c.created = rec.created; + c.topic = rec.topic; + for (tag, ban) in rec.lists { + match tag { + 'b' => c.bans.push(ban), + 'e' => c.excepts.push(ban), + 'I' => c.invex.push(ban), + 'g' => c.filters.push(ban), + 'X' => c.exemptchanops.push(ban), + 'w' => c.autoop.push(ban), + _ => {} + } + } + s.channels.insert(key.clone(), c); + apply_modes(s, &rec.name, &key, &rec.modes); + // guarantee permanence even if the stored mode string somehow lost the 'P' + if let Some(c) = s.channels.get_mut(&key) { + c.modes.permanent = true; + } +} + +/// Restore permanent channels from disk. Called once at startup, before links come +/// up (so the TS we assign can't desync a peer, matching how the DB is written). +pub fn load(s: &mut Server) { + let Ok(text) = fs::read_to_string(db_path(s)) else { + return; + }; + let mut cur: Option = None; + let mut count = 0usize; + for line in text.lines() { + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((tag, rest)) = line.split_once(' ') else { + continue; + }; + match tag { + "C" => { + if let Some(rec) = cur.take() { + build(s, rec); + count += 1; + } + let mut it = rest.split_whitespace(); + let name = it.next().unwrap_or_default().to_string(); + let created = it.next().and_then(|v| v.parse().ok()).unwrap_or_else(crate::server::now); + cur = Some(Record { + name, + created, + ..Record::default() + }); + } + "M" => { + if let Some(rec) = cur.as_mut() { + rec.modes = rest.to_string(); + } + } + "T" => { + if let Some(rec) = cur.as_mut() { + // " :" + let mut it = rest.splitn(3, ' '); + if let (Some(ts), Some(setter), Some(text)) = (it.next(), it.next(), it.next()) { + if let Ok(ts) = ts.parse() { + rec.topic = Some(Topic { + text: text.strip_prefix(':').unwrap_or(text).to_string(), + setter: setter.to_string(), + ts, + }); + } + } + } + } + "b" | "e" | "I" | "g" | "X" | "w" => { + if let Some(rec) = cur.as_mut() { + // " " + let mut it = rest.splitn(3, ' '); + if let (Some(ts), Some(setter), Some(mask)) = (it.next(), it.next(), it.next()) { + if let Ok(ts) = ts.parse() { + rec.lists.push(( + tag.chars().next().unwrap(), + Ban { + mask: mask.to_string(), + setter: setter.to_string(), + ts, + expires: None, + }, + )); + } + } + } + } + _ => {} + } + } + if let Some(rec) = cur.take() { + build(s, rec); + count += 1; + } + if count > 0 { + eprintln!("[permchannels] restored {count} permanent channel(s)"); + } +} + +/// Timer/command-driven persister. Holds the last bytes written so an unchanged +/// network never rewrites the file. +#[derive(Default)] +pub struct PermChannels { + last: String, +} + +impl PermChannels { + /// Re-serialise and, if anything changed, write the database (or remove it when + /// the last permanent channel is gone). + fn flush(&mut self, s: &Server) { + let cur = serialize(s); + if cur == self.last { + return; + } + let path = db_path(s); + if cur.is_empty() { + let _ = fs::remove_file(&path); + } else if let Err(e) = atomic_write(&path, &cur) { + eprintln!("[permchannels] cannot write {path}: {e}"); + return; // leave `last` stale so the next tick retries + } + self.last = cur; + } +} + +impl Module for PermChannels { + fn name(&self) -> &'static str { + "permchannels" + } + + fn on_post_command(&mut self, srv: &mut Server, _uid: Uid, cmd: &str) { + // Persist promptly when a command could have changed a permanent channel's + // stored state (its modes, incl. +P/-P itself, or its topic). + if cmd.eq_ignore_ascii_case("MODE") + || cmd.eq_ignore_ascii_case("SAMODE") + || cmd.eq_ignore_ascii_case("TOPIC") + { + self.flush(srv); + } + } + + fn on_tick(&mut self, srv: &mut Server) { + self.flush(srv); // backstop for changes that arrived off the command path (e.g. S2S) + } +}