diff --git a/api/src/lib.rs b/api/src/lib.rs index 14b0a3d..92c9ced 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -780,10 +780,11 @@ pub trait Store { fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64; fn news_del(&mut self, id: u64) -> bool; fn news(&self, kind: &str) -> Vec; - // Runtime operator grants (OperServ OPER), merged with config opers. - fn oper_grant(&mut self, account: &str, privs: Vec); + // Runtime operator grants (OperServ OPER), merged with config opers. `expires` + // is an absolute unix time (None = permanent); opers_list hides expired ones. + fn oper_grant(&mut self, account: &str, privs: Vec, expires: Option); fn oper_revoke(&mut self, account: &str) -> bool; - fn opers_list(&self) -> Vec<(String, Vec)>; + fn opers_list(&self) -> Vec<(String, Vec, Option)>; // Session-limit exceptions (OperServ SESSION EXCEPTION). fn session_except_add(&mut self, mask: &str, limit: u32, reason: &str); fn session_except_del(&mut self, mask: &str) -> bool; diff --git a/operserv/src/oper.rs b/operserv/src/oper.rs index 63265a9..d06d2c2 100644 --- a/operserv/src/oper.rs +++ b/operserv/src/oper.rs @@ -1,24 +1,26 @@ -use fedserv_api::{Priv, Sender, ServiceCtx, Store}; +use fedserv_api::{parse_duration, Priv, Sender, ServiceCtx, Store}; +use std::time::{SystemTime, UNIX_EPOCH}; -// OPER ADD | OPER DEL | OPER LIST: manage -// runtime services operators (merged with the declarative config opers). The -// privileges are auspex, suspend, admin. Admin-only — only an admin grants oper. +// OPER ADD [+duration] | OPER DEL | OPER LIST: +// manage runtime services operators (merged with the declarative config opers). +// The privileges are auspex, suspend, admin; a +duration makes the grant expire. +// Admin-only — only an admin grants oper. 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 — OPER needs the \x02admin\x02 privilege."); return; } match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { - Some("ADD") | Some("SET") => add(me, from, args.get(2).copied(), args.get(3).copied(), ctx, db), + Some("ADD") | Some("SET") => add(me, from, args.get(2).copied(), args.get(3).copied(), args.get(4).copied(), ctx, db), Some("DEL") | Some("REMOVE") => del(me, from, args.get(2).copied(), ctx, db), Some("LIST") | Some("VIEW") => list(me, from, ctx, db), - _ => ctx.notice(me, from.uid, "Syntax: OPER ADD | OPER DEL | OPER LIST — privs: auspex, suspend, admin"), + _ => ctx.notice(me, from.uid, "Syntax: OPER ADD [+duration] | OPER DEL | OPER LIST — privs: auspex, suspend, admin"), } } -fn add(me: &str, from: &Sender, account: Option<&str>, privs: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { +fn add(me: &str, from: &Sender, account: Option<&str>, privs: Option<&str>, dur: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { let (Some(account), Some(privs)) = (account, privs) else { - ctx.notice(me, from.uid, "Syntax: OPER ADD "); + ctx.notice(me, from.uid, "Syntax: OPER ADD [+duration]"); return; }; let Some(account) = db.resolve_account(account).map(str::to_string) else { @@ -35,8 +37,14 @@ fn add(me: &str, from: &Sender, account: Option<&str>, privs: Option<&str>, ctx: ctx.notice(me, from.uid, "No valid privileges given (auspex, suspend, admin)."); return; } - db.oper_grant(&account, names.clone()); - ctx.notice(me, from.uid, format!("\x02{account}\x02 is now an operator ({}).", names.join(", "))); + let expires = dur.and_then(|d| d.strip_prefix('+')).and_then(parse_duration).map(|secs| now() + secs); + db.oper_grant(&account, names.clone(), expires); + let window = if expires.is_some() { " (temporary)" } else { "" }; + ctx.notice(me, from.uid, format!("\x02{account}\x02 is now an operator ({}){window}.", names.join(", "))); +} + +fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } fn del(me: &str, from: &Sender, account: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { @@ -58,8 +66,9 @@ fn list(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) { ctx.notice(me, from.uid, "No runtime operators (config opers are set in the daemon config)."); return; } - for (account, privs) in &opers { - ctx.notice(me, from.uid, format!(" \x02{account}\x02 — {}", privs.join(", "))); + for (account, privs, expires) in &opers { + let window = if expires.is_some() { " (temporary)" } else { "" }; + ctx.notice(me, from.uid, format!(" \x02{account}\x02 — {}{window}", privs.join(", "))); } ctx.notice(me, from.uid, format!("End of runtime operator list ({} shown).", opers.len())); } diff --git a/src/engine/db.rs b/src/engine/db.rs index f461cf8..6e2d113 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -189,7 +189,12 @@ pub enum Event { NewsAdded { id: u64, kind: String, text: String, setter: String, ts: u64 }, NewsDeleted { id: u64 }, // Runtime operator grants (OperServ OPER). - OperGranted { account: String, privs: Vec }, + OperGranted { + account: String, + privs: Vec, + #[serde(default)] + expires: Option, + }, OperRevoked { account: String }, // Session-limit exceptions (OperServ SESSION). SessionExceptionAdded { mask: String, limit: u32, reason: String }, @@ -378,9 +383,9 @@ pub struct NetData { pub akills: Vec, pub news: Vec, pub news_seq: u64, - // Runtime services operators (OperServ OPER), casefolded account -> privilege - // names. Merged with the declarative [[oper]] config at the engine. - pub opers: HashMap>, + // Runtime services operators (OperServ OPER), casefolded account -> grant. + // Merged with the declarative [[oper]] config at the engine. + pub opers: HashMap, // Session-limit exceptions (OperServ SESSION): per-IP-mask allowances. pub sess_exceptions: Vec, } @@ -394,6 +399,14 @@ pub struct SessionException { pub reason: String, } +// A runtime operator grant: the privilege names and an optional absolute-unix +// expiry (None = permanent), evaluated lazily like suspensions/vhosts. +#[derive(Debug, Clone)] +pub struct OperGrant { + pub privs: Vec, + pub expires: Option, +} + // A memo left for an account (MemoServ). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Memo { @@ -1129,8 +1142,8 @@ impl Db { for n in &self.net.news { snapshot.push(Event::NewsAdded { id: n.id, kind: n.kind.clone(), text: n.text.clone(), setter: n.setter.clone(), ts: n.ts }); } - for (account, privs) in &self.net.opers { - snapshot.push(Event::OperGranted { account: account.clone(), privs: privs.clone() }); + for (account, grant) in &self.net.opers { + snapshot.push(Event::OperGranted { account: account.clone(), privs: grant.privs.clone(), expires: grant.expires }); } for e in &self.net.sess_exceptions { snapshot.push(Event::SessionExceptionAdded { mask: e.mask.clone(), limit: e.limit, reason: e.reason.clone() }); @@ -1968,10 +1981,11 @@ impl Db { .max_by_key(|&l| if l == 0 { u32::MAX } else { l }) } - /// Grant runtime operator privileges to an account (replaces any existing). - pub fn oper_grant(&mut self, account: &str, privs: Vec) { - let _ = self.log.append(Event::OperGranted { account: account.to_string(), privs: privs.clone() }); - self.net.opers.insert(key(account), privs); + /// Grant runtime operator privileges to an account (replaces any existing), + /// optionally expiring at an absolute unix time. + pub fn oper_grant(&mut self, account: &str, privs: Vec, expires: Option) { + let _ = self.log.append(Event::OperGranted { account: account.to_string(), privs: privs.clone(), expires }); + self.net.opers.insert(key(account), OperGrant { privs, expires }); } /// Revoke a runtime operator grant. Returns whether one existed. @@ -1984,15 +1998,26 @@ impl Db { true } - /// The runtime operator grants, as (account, privilege-names). - pub fn opers_list(&self) -> Vec<(String, Vec)> { - self.net.opers.iter().map(|(a, p)| (a.clone(), p.clone())).collect() + /// The live runtime operator grants, as (account, privilege-names, expiry); + /// expired ones are hidden. + pub fn opers_list(&self) -> Vec<(String, Vec, Option)> { + let now = now(); + self.net + .opers + .iter() + .filter(|(_, g)| g.expires.is_none_or(|e| e > now)) + .map(|(a, g)| (a.clone(), g.privs.clone(), g.expires)) + .collect() } - /// The runtime privileges granted to an account, if any (config opers are - /// merged in separately by the engine). - pub fn oper_privs_of(&self, account: &str) -> Option { - self.net.opers.get(&key(account)).map(|names| Privs::from_names(names)) + /// The runtime privileges granted to an account as of `now`, if the grant is + /// present and unexpired (config opers are merged in separately by the engine). + pub fn oper_privs_of(&self, account: &str, now: u64) -> Option { + self.net + .opers + .get(&key(account)) + .filter(|g| g.expires.is_none_or(|e| e > now)) + .map(|g| Privs::from_names(&g.privs)) } /// Add a news item of `kind` ("logon"/"oper"). Returns its stable id. @@ -3027,8 +3052,8 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { net.news.retain(|n| n.id != id); } - Event::OperGranted { account, privs } => { - net.opers.insert(key(&account), privs); + Event::OperGranted { account, privs, expires } => { + net.opers.insert(key(&account), OperGrant { privs, expires }); } Event::OperRevoked { account } => { net.opers.remove(&key(&account)); @@ -3320,13 +3345,13 @@ impl Store for Db { fn news(&self, kind: &str) -> Vec { Db::news(self, kind) } - fn oper_grant(&mut self, account: &str, privs: Vec) { - Db::oper_grant(self, account, privs) + fn oper_grant(&mut self, account: &str, privs: Vec, expires: Option) { + Db::oper_grant(self, account, privs, expires) } fn oper_revoke(&mut self, account: &str) -> bool { Db::oper_revoke(self, account) } - fn opers_list(&self) -> Vec<(String, Vec)> { + fn opers_list(&self) -> Vec<(String, Vec, Option)> { Db::opers_list(self) } fn session_except_add(&mut self, mask: &str, limit: u32, reason: &str) { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 1419d2c..2496712 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -255,7 +255,7 @@ impl Engine { // make an account an operator. fn oper_privs(&self, account: &str) -> Privs { let config = self.opers.get(&account.to_ascii_lowercase()).copied().unwrap_or_default(); - match self.db.oper_privs_of(account) { + match self.db.oper_privs_of(account, self.now_secs()) { Some(runtime) => config.union(runtime), None => config, } @@ -1671,7 +1671,7 @@ fn audit_summary(event: &db::Event) -> Option { }, NewsAdded { kind, .. } => format!("added a \x02{kind}\x02 news item"), NewsDeleted { .. } => "removed a news item".to_string(), - OperGranted { account, privs } => format!("granted \x02{account}\x02 operator ({})", privs.join(", ")), + OperGranted { account, privs, .. } => format!("granted \x02{account}\x02 operator ({})", privs.join(", ")), OperRevoked { account } => format!("revoked \x02{account}\x02's operator access"), SessionExceptionAdded { mask, limit, .. } => format!("set a session exception \x02{mask}\x02 (limit {limit})"), SessionExceptionRemoved { mask } => format!("removed the session exception \x02{mask}\x02"), @@ -4393,6 +4393,45 @@ mod tests { assert!(os(&mut e, "000AAAAAS", "OPER DEL staff").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("isn't a runtime operator"))), "config oper untouched"); } + // A temporary OPER grant confers privileges until it lazily expires. + #[test] + fn operserv_oper_grant_can_expire() { + use fedserv_operserv::OperServ; + let path = std::env::temp_dir().join("fedserv-opertmp.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("staff", "password1", None).unwrap(); + db.register("temp", "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() }); + let denied = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAP".into(), nick: "temp".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAP".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + + // A one-hour admin grant is active now... + os(&mut e, "000AAAAAS", "OPER ADD temp admin +1h"); + assert!(!denied(&os(&mut e, "000AAAAAP", "STATS")), "active while granted"); + assert!(os(&mut e, "000AAAAAS", "OPER LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("temp") && text.contains("temporary"))), "listed as temporary"); + + // ...but not after its window passes. + e.now_override = Some(10_000_000_000); + assert!(denied(&os(&mut e, "000AAAAAP", "STATS")), "expired grant confers nothing"); + } + // OperServ NEWS: logon news greets everyone on connect, oper news greets an // operator on login; ADD/DEL/LIST are admin-only. #[test]