permchannels: persist +P channels (modes, topic, ts, list modes) to disk and restore them at startup

This commit is contained in:
Jean Chevronnet 2026-08-17 01:43:29 +00:00
parent 978f8f8f82
commit b451ccbfd8
4 changed files with 288 additions and 0 deletions

View file

@ -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

View file

@ -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<dyn Module>> {
Box::new(autoop::AutoOp),
Box::new(autodrop::AutoDrop),
Box::new(operprefix::OperPrefix),
Box::new(permchannels::PermChannels::default()),
]
}

281
src/modules/permchannels.rs Normal file
View file

@ -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 `<conf>.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 `<conf>.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<Topic>,
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<Record> = 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() {
// "<ts> <setter> :<text>"
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() {
// "<ts> <setter> <mask>"
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)
}
}