From 1daebc94a554d5ed90d3d69c429cf87842046109 Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 14 Jul 2026 01:51:07 +0000 Subject: [PATCH] =?UTF-8?q?OperServ:=20JUPE=20=E2=80=94=20hold=20a=20serve?= =?UTF-8?q?r=20name=20against=20a=20rogue=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JUPE [reason] introduces a fake server holding the name so the real one can't link; JUPE DEL squits it; JUPE LIST shows them. Node-local (the introducing node owns the jupe, so it isn't federated — that would collide SIDs across nodes) and re-asserted at burst. A fake server id is allocated per jupe. New JupeServer/Squit actions serialise to the mid-link SERVER introduction and SQUIT. Admin-only. --- api/src/lib.rs | 20 ++++++++++++++++ inspircd/src/lib.rs | 5 ++++ operserv/src/jupe.rs | 49 ++++++++++++++++++++++++++++++++++++++ operserv/src/lib.rs | 5 +++- src/engine/db.rs | 56 +++++++++++++++++++++++++++++++++++++++++++- src/engine/mod.rs | 46 ++++++++++++++++++++++++++++++++++++ 6 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 operserv/src/jupe.rs diff --git a/api/src/lib.rs b/api/src/lib.rs index 1a6cdeb..5c439bd 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -94,6 +94,11 @@ pub enum NetAction { DelLine { kind: String, mask: String }, // Disconnect a user from the network, sourced from pseudoclient `from`. KillUser { from: String, uid: String, reason: String }, + // Introduce a juped server (a fake server holding a name so the real one + // can't link), sourced from our server. `sid` is its allocated server id. + JupeServer { name: String, sid: String, reason: String }, + // Delink a server (used to lift a jupe), by name or sid. + Squit { target: String, reason: String }, Raw(String), // Internal only, never serialized to the wire: a registration whose password // still needs its (expensive) key derivation. The link layer runs the @@ -405,6 +410,16 @@ impl ServiceCtx { pub fn global(&mut self, from: &str, text: impl Into) { self.actions.push(NetAction::Notice { from: from.to_string(), to: "$*".to_string(), text: text.into() }); } + + // Introduce a juped server holding `name` (with allocated `sid`). + pub fn jupe(&mut self, name: &str, sid: &str, reason: &str) { + self.actions.push(NetAction::JupeServer { name: name.to_string(), sid: sid.to_string(), reason: reason.to_string() }); + } + + // Delink a server by name or sid (lifts a jupe). + pub fn squit(&mut self, target: &str, reason: &str) { + self.actions.push(NetAction::Squit { target: target.to_string(), reason: reason.to_string() }); + } } // --------------------------------------------------------------------------- @@ -751,6 +766,11 @@ pub trait Store { fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option); fn ignore_del(&mut self, mask: &str) -> bool; fn ignores(&self) -> Vec; + // Juped servers (node-local, oper-only). jupe_add returns the allocated sid; + // jupe_del returns the sid to squit if it existed. + fn jupe_add(&mut self, name: &str, reason: &str) -> String; + fn jupe_del(&mut self, name: &str) -> Option; + fn jupes(&self) -> Vec<(String, String, String)>; // Staff notes on accounts/channels (oper-only), shown in INFO to operators. fn set_account_note(&mut self, account: &str, note: Option) -> bool; fn account_note(&self, account: &str) -> Option; diff --git a/inspircd/src/lib.rs b/inspircd/src/lib.rs index 955a520..7f56bcc 100644 --- a/inspircd/src/lib.rs +++ b/inspircd/src/lib.rs @@ -329,6 +329,11 @@ impl Protocol for InspIrcd { } NetAction::DelLine { kind, mask } => vec![self.sourced(format!("DELLINE {} {}", kind, mask))], NetAction::KillUser { from, uid, reason } => vec![format!(":{} KILL {} :{}", from, uid, reason)], + // Introduce a server behind us: : SERVER :. + NetAction::JupeServer { name, sid, reason } => { + vec![self.sourced(format!("SERVER {} {} :JUPED: {}", name, sid, reason))] + } + NetAction::Squit { target, reason } => vec![self.sourced(format!("SQUIT {} :{}", target, reason))], NetAction::Raw(s) => vec![s.clone()], // Internal: the link layer handles these before serialization. NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } | NetAction::SendEmail { .. } => vec![], diff --git a/operserv/src/jupe.rs b/operserv/src/jupe.rs new file mode 100644 index 0000000..87b80e1 --- /dev/null +++ b/operserv/src/jupe.rs @@ -0,0 +1,49 @@ +use fedserv_api::{Priv, Sender, ServiceCtx, Store}; + +// JUPE [reason] | JUPE DEL | JUPE LIST: hold a +// server name with a fake server so a rogue one can't link (or lift it). Admin- +// only. Node-local: the introducing node owns the jupe. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !from.privs.has(Priv::Admin) { + ctx.notice(me, from.uid, "Access denied — JUPE needs the \x02admin\x02 privilege."); + return; + } + match args.get(1) { + Some(&sub) if sub.eq_ignore_ascii_case("DEL") || sub.eq_ignore_ascii_case("REMOVE") => del(me, from, args.get(2).copied(), ctx, db), + Some(&sub) if sub.eq_ignore_ascii_case("LIST") => list(me, from, ctx, db), + Some(&name) if name.contains('.') => { + let reason = if args.len() > 2 { args[2..].join(" ") } else { "Juped by services".to_string() }; + let by = from.account.unwrap_or(from.nick); + let sid = db.jupe_add(name, &format!("({by}) {reason}")); + ctx.jupe(name, &sid, &format!("({by}) {reason}")); + ctx.notice(me, from.uid, format!("\x02{name}\x02 is now juped.")); + } + _ => ctx.notice(me, from.uid, "Syntax: JUPE [reason] | JUPE DEL | JUPE LIST"), + } +} + +fn del(me: &str, from: &Sender, name: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(name) = name else { + ctx.notice(me, from.uid, "Syntax: JUPE DEL "); + return; + }; + match db.jupe_del(name) { + Some(sid) => { + ctx.squit(&sid, "Jupe lifted"); + ctx.notice(me, from.uid, format!("The jupe on \x02{name}\x02 has been lifted.")); + } + None => ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't juped.")), + } +} + +fn list(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let jupes = db.jupes(); + if jupes.is_empty() { + ctx.notice(me, from.uid, "No servers are juped."); + return; + } + for (name, sid, reason) in &jupes { + ctx.notice(me, from.uid, format!(" \x02{name}\x02 ({sid}) — {reason}")); + } + ctx.notice(me, from.uid, format!("End of jupe list ({} shown).", jupes.len())); +} diff --git a/operserv/src/lib.rs b/operserv/src/lib.rs index a26f245..c62cab9 100644 --- a/operserv/src/lib.rs +++ b/operserv/src/lib.rs @@ -29,6 +29,8 @@ mod news; mod oper; #[path = "session.rs"] mod session; +#[path = "jupe.rs"] +mod jupe; pub struct OperServ { pub uid: String, @@ -69,6 +71,7 @@ impl Service for OperServ { Some(cmd) if cmd.eq_ignore_ascii_case("OPER") => oper::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("SESSION") => session::handle_session(me, from, args, ctx, net), Some(cmd) if cmd.eq_ignore_ascii_case("EXCEPTION") => session::handle_exception(me, from, args, ctx, db), + Some(cmd) if cmd.eq_ignore_ascii_case("JUPE") => jupe::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("HELP") => help(me, from, ctx), None => help(me, from, ctx), Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02HELP\x02.")), @@ -77,5 +80,5 @@ impl Service for OperServ { } fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) { - ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02GLOBAL\x02 (announce to everyone), \x02KILL\x02 [reason] (disconnect a user), \x02KICK\x02 <#chan> [reason], \x02MODE\x02 <#chan> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 , \x02SVSJOIN\x02 <#chan> [key], \x02INFO\x02 ADD|DEL (staff notes), \x02NEWS\x02 ADD|DEL|LIST (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits)."); + ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02GLOBAL\x02 (announce to everyone), \x02KILL\x02 [reason] (disconnect a user), \x02KICK\x02 <#chan> [reason], \x02MODE\x02 <#chan> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 , \x02SVSJOIN\x02 <#chan> [key], \x02INFO\x02 ADD|DEL (staff notes), \x02NEWS\x02 ADD|DEL|LIST (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 | DEL | LIST."); } diff --git a/src/engine/db.rs b/src/engine/db.rs index 63ed62c..f461cf8 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -338,6 +338,15 @@ fn gline_kind() -> String { "G".to_string() } +// A valid 3-char server id for a juped server from a counter: a leading digit +// (kept high to avoid colliding with typical real SIDs) then two base-36 chars. +fn jupe_sid(seq: u32) -> String { + const C: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let b = C[(seq / 36 % 36) as usize]; + let c = C[(seq % 36) as usize]; + String::from_utf8(vec![b'9', b, c]).unwrap() +} + // A services ignore: services silently drop commands from a matching user. Node- // local and in-memory (like the auth throttle) — a fast, transient moderation // tool, not persisted or federated. @@ -956,6 +965,18 @@ pub struct Db { net: NetData, // Services ignores (OperServ IGNORE), node-local and in-memory. ignores: Vec, + // Juped servers (OperServ JUPE), node-local: the introducing node owns them, + // so they aren't federated (that would collide SIDs across nodes). + jupes: Vec, + jupe_seq: u32, +} + +// A juped server: the held name, the fake server id we allocated for it, and why. +#[derive(Debug, Clone)] +pub struct Jupe { + pub name: String, + pub sid: String, + pub reason: String, } // Network-wide HostServ configuration, rebuilt from the event log. @@ -1011,7 +1032,7 @@ impl Db { apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event); } tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); - Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new() } + Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), jupes: Vec::new(), jupe_seq: 0 } } /// Fold an entry authored by another node into the store — the services-side @@ -2004,6 +2025,30 @@ impl Db { .collect() } + /// Jupe a server name: allocate a fake sid, store it, return the sid to + /// introduce (or the existing sid if the name is already juped). + pub fn jupe_add(&mut self, name: &str, reason: &str) -> String { + if let Some(j) = self.jupes.iter().find(|j| j.name.eq_ignore_ascii_case(name)) { + return j.sid.clone(); + } + let sid = jupe_sid(self.jupe_seq); + self.jupe_seq += 1; + self.jupes.push(Jupe { name: name.to_string(), sid: sid.clone(), reason: reason.to_string() }); + sid + } + + /// Lift a jupe. Returns the sid to squit if it existed. + pub fn jupe_del(&mut self, name: &str) -> Option { + let sid = self.jupes.iter().find(|j| j.name.eq_ignore_ascii_case(name)).map(|j| j.sid.clone())?; + self.jupes.retain(|j| !j.name.eq_ignore_ascii_case(name)); + Some(sid) + } + + /// The juped servers, as (name, sid, reason). + pub fn jupes(&self) -> Vec<(String, String, String)> { + self.jupes.iter().map(|j| (j.name.clone(), j.sid.clone(), j.reason.clone())).collect() + } + /// Add a services ignore, replacing any existing entry for the same mask. pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option) { self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask)); @@ -3245,6 +3290,15 @@ impl Store for Db { fn ignores(&self) -> Vec { Db::ignores(self) } + fn jupe_add(&mut self, name: &str, reason: &str) -> String { + Db::jupe_add(self, name, reason) + } + fn jupe_del(&mut self, name: &str) -> Option { + Db::jupe_del(self, name) + } + fn jupes(&self) -> Vec<(String, String, String)> { + Db::jupes(self) + } fn set_account_note(&mut self, account: &str, note: Option) -> bool { Db::set_account_note(self, account, note) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index f754663..2cc73cc 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -719,6 +719,10 @@ impl Engine { let duration = a.expires.map(|e| e.saturating_sub(now)).unwrap_or(0); out.push(NetAction::AddLine { kind: a.kind, mask: a.mask, setter: a.setter, duration, reason: a.reason }); } + // Re-introduce our juped servers over the fresh link. + for (name, sid, reason) in self.db.jupes() { + out.push(NetAction::JupeServer { name, sid, reason }); + } out.push(NetAction::Metadata { target: "*".to_string(), key: "saslmechlist".to_string(), @@ -4086,6 +4090,48 @@ mod tests { } } + // OperServ JUPE: hold a server name with a fake server, re-assert it at burst, + // and lift it with a squit. Admin-only. + #[test] + fn operserv_jupe_holds_and_lifts() { + use fedserv_operserv::OperServ; + let path = std::env::temp_dir().join("fedserv-osjupe.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("staff", "password1", None).unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(OperServ { uid: "42SAAAAAH".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::Admin)); + e.set_opers(opers); + let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() }); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "9.9.9.9".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + + // Juping introduces a fake server holding the name. + let out = os(&mut e, "000AAAAAS", "JUPE rogue.example evil twin"); + assert!(out.iter().any(|a| matches!(a, NetAction::JupeServer { name, .. } if name == "rogue.example")), "jupe introduced: {out:?}"); + // A bare name (no dot) isn't a server — syntax refused. + assert!(os(&mut e, "000AAAAAS", "JUPE notaserver").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Syntax"))), "needs a server name"); + + // It's re-introduced at burst. + assert!(e.startup_actions().iter().any(|a| matches!(a, NetAction::JupeServer { name, .. } if name == "rogue.example")), "re-asserted at burst"); + // LIST shows it. + assert!(os(&mut e, "000AAAAAS", "JUPE LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("rogue.example"))), "listed"); + + // DEL squits the fake server. + assert!(os(&mut e, "000AAAAAS", "JUPE DEL rogue.example").iter().any(|a| matches!(a, NetAction::Squit { .. })), "squit on lift"); + assert!(!e.startup_actions().iter().any(|a| matches!(a, NetAction::JupeServer { .. })), "gone after DEL"); + } + // OperServ session limiting: the connection that puts an IP over its limit is // killed; SESSION inspects counts; EXCEPTION raises the allowance per IP-mask. #[test]