From 1edf250952b40296f208480e7b60c0f9b13eaa78 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 21:30:58 +0000 Subject: [PATCH] HostServ: vhost request/approval flow REQUEST records a pending vhost (typed Option on the account, VhostRequested/VhostRequestCleared events); operators review with WAITING and either ACTIVATE (assign + apply to online sessions) or REJECT (clear it). Host validation and the operator gate are shared with SET. --- api/src/lib.rs | 3 ++ hostserv/src/approve.rs | 38 +++++++++++++++++++++ hostserv/src/lib.rs | 12 ++++++- hostserv/src/request.rs | 21 ++++++++++++ hostserv/src/waiting.rs | 18 ++++++++++ src/engine/db.rs | 76 +++++++++++++++++++++++++++++++++++++++-- src/engine/mod.rs | 50 ++++++++++++++++++++++++++- src/grpc.rs | 3 ++ 8 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 hostserv/src/approve.rs create mode 100644 hostserv/src/request.rs create mode 100644 hostserv/src/waiting.rs diff --git a/api/src/lib.rs b/api/src/lib.rs index 415c11c..95a22ba 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -586,6 +586,9 @@ pub trait Store { fn del_vhost(&mut self, account: &str) -> Result; fn vhost(&self, account: &str) -> Option; fn vhosts(&self) -> Vec; + fn request_vhost(&mut self, account: &str, host: &str) -> Result<(), RegError>; + fn take_vhost_request(&mut self, account: &str) -> Result, RegError>; + fn vhost_requests(&self) -> Vec<(String, String)>; fn group_nick(&mut self, nick: &str, account: &str) -> Result<(), RegError>; fn ungroup_nick(&mut self, nick: &str) -> Result; fn drop_account(&mut self, account: &str) -> Result; diff --git a/hostserv/src/approve.rs b/hostserv/src/approve.rs new file mode 100644 index 0000000..10237a0 --- /dev/null +++ b/hostserv/src/approve.rs @@ -0,0 +1,38 @@ +use fedserv_api::{NetView, Sender, ServiceCtx, Store}; + +// ACTIVATE / REJECT : approve a pending vhost request (setting +// and applying it) or turn it down. Operators only. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store, activate: bool) { + if !super::require_oper(me, from, ctx) { + return; + } + let Some(&account) = args.get(1) else { + let syntax = if activate { "Syntax: ACTIVATE " } else { "Syntax: REJECT " }; + ctx.notice(me, from.uid, syntax); + return; + }; + let host = match db.take_vhost_request(account) { + Ok(Some(h)) => h, + Ok(None) => { + ctx.notice(me, from.uid, format!("\x02{account}\x02 has no pending vhost request.")); + return; + } + Err(_) => { + ctx.notice(me, from.uid, format!("\x02{account}\x02 isn't registered.")); + return; + } + }; + if !activate { + ctx.notice(me, from.uid, format!("Rejected \x02{account}\x02's vhost request.")); + return; + } + match db.set_vhost(account, &host, from.nick) { + Ok(()) => { + for uid in net.uids_logged_into(account) { + ctx.set_host(&uid, &host); + } + ctx.notice(me, from.uid, format!("Activated vhost \x02{host}\x02 for \x02{account}\x02.")); + } + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} diff --git a/hostserv/src/lib.rs b/hostserv/src/lib.rs index f181ca7..811858b 100644 --- a/hostserv/src/lib.rs +++ b/hostserv/src/lib.rs @@ -15,6 +15,12 @@ mod set; mod del; #[path = "list.rs"] mod list; +#[path = "request.rs"] +mod request; +#[path = "waiting.rs"] +mod waiting; +#[path = "approve.rs"] +mod approve; pub struct HostServ { pub uid: String, @@ -39,7 +45,11 @@ impl Service for HostServ { Some("SET") => set::handle(me, from, args, ctx, net, db), Some("DEL") => del::handle(me, from, args, ctx, net, db), Some("LIST") => list::handle(me, from, ctx, db), - Some("HELP") | None => ctx.notice(me, from.uid, "HostServ gives you a vhost: \x02ON\x02 activates your assigned vhost, \x02OFF\x02 restores your normal host. Operators use \x02SET\x02 , \x02DEL\x02 and \x02LIST\x02."), + Some("REQUEST") => request::handle(me, from, args, ctx, db), + Some("WAITING") => waiting::handle(me, from, ctx, db), + Some("ACTIVATE") | Some("APPROVE") => approve::handle(me, from, args, ctx, net, db, true), + Some("REJECT") => approve::handle(me, from, args, ctx, net, db, false), + Some("HELP") | None => ctx.notice(me, from.uid, "HostServ gives you a vhost: \x02ON\x02 activates your assigned vhost, \x02OFF\x02 restores your normal host, \x02REQUEST\x02 asks for one. Operators use \x02SET\x02/\x02DEL\x02 , \x02LIST\x02, and \x02WAITING\x02 + \x02ACTIVATE\x02/\x02REJECT\x02 for requests."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), } } diff --git a/hostserv/src/request.rs b/hostserv/src/request.rs new file mode 100644 index 0000000..6c9720b --- /dev/null +++ b/hostserv/src/request.rs @@ -0,0 +1,21 @@ +use fedserv_api::{Sender, ServiceCtx, Store}; + +// REQUEST : ask for a vhost, to be approved by an operator. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(account) = from.account else { + ctx.notice(me, from.uid, "You need to identify to NickServ first."); + return; + }; + let Some(&host) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: REQUEST "); + return; + }; + if !super::valid_vhost(host) { + ctx.notice(me, from.uid, format!("\x02{host}\x02 isn't a valid host (letters, digits, hyphens and dots).")); + return; + } + match db.request_vhost(account, host) { + Ok(()) => ctx.notice(me, from.uid, format!("Requested vhost \x02{host}\x02 — an operator will review it.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} diff --git a/hostserv/src/waiting.rs b/hostserv/src/waiting.rs new file mode 100644 index 0000000..e79a078 --- /dev/null +++ b/hostserv/src/waiting.rs @@ -0,0 +1,18 @@ +use fedserv_api::{Sender, ServiceCtx, Store}; + +// WAITING: pending vhost requests awaiting approval. Operators only. +pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) { + if !super::require_oper(me, from, ctx) { + return; + } + let requests = db.vhost_requests(); + if requests.is_empty() { + ctx.notice(me, from.uid, "No vhost requests are waiting."); + return; + } + ctx.notice(me, from.uid, format!("Pending vhost requests ({}):", requests.len())); + for (account, host) in &requests { + ctx.notice(me, from.uid, format!(" \x02{account}\x02 — {host}")); + } + ctx.notice(me, from.uid, "Approve with \x02ACTIVATE\x02 or turn down with \x02REJECT\x02 ."); +} diff --git a/src/engine/db.rs b/src/engine/db.rs index c8fc080..e7c30a8 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -76,6 +76,16 @@ pub struct Account { // Assigned vhost (HostServ), applied to the displayed host on identify. #[serde(default)] pub vhost: Option, + // A vhost the user has requested, pending operator approval. + #[serde(default)] + pub vhost_request: Option, +} + +// A requested vhost awaiting approval. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingVhost { + pub host: String, + pub ts: u64, } // A HostServ virtual host: the displayed host, who assigned it, and when. @@ -108,6 +118,8 @@ pub enum Event { AjoinRemoved { account: String, channel: String }, VhostSet { account: String, host: String, setter: String, ts: u64 }, VhostDeleted { account: String }, + VhostRequested { account: String, host: String, ts: u64 }, + VhostRequestCleared { account: String }, AccountSuspended { account: String, by: String, reason: String, ts: u64, expires: Option }, AccountUnsuspended { account: String }, MemoSent { account: String, from: String, text: String, ts: u64 }, @@ -163,6 +175,8 @@ impl Event { | Event::AjoinRemoved { .. } | Event::VhostSet { .. } | Event::VhostDeleted { .. } + | Event::VhostRequested { .. } + | Event::VhostRequestCleared { .. } | Event::AccountSuspended { .. } | Event::AccountUnsuspended { .. } | Event::MemoSent { .. } @@ -986,6 +1000,7 @@ impl Db { memos: Vec::new(), greet: String::new(), vhost: None, + vhost_request: None, }; self.log.append(Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?; self.accounts.insert(key(name), account); @@ -1185,6 +1200,44 @@ impl Db { self.accounts.get(&key(account)).and_then(|a| a.vhost.as_ref()) } + /// Record a pending vhost request for `account` (awaiting approval). + pub fn request_vhost(&mut self, account: &str, host: &str) -> Result<(), RegError> { + let k = key(account); + if !self.accounts.contains_key(&k) { + return Err(RegError::Internal); + } + let ts = now(); + self.log.append(Event::VhostRequested { account: account.to_string(), host: host.to_string(), ts }).map_err(|_| RegError::Internal)?; + self.accounts.get_mut(&k).unwrap().vhost_request = Some(PendingVhost { host: host.to_string(), ts }); + Ok(()) + } + + /// Clear a pending vhost request (on approval or rejection). Returns the + /// requested host, if there was one. + pub fn take_vhost_request(&mut self, account: &str) -> Result, RegError> { + let k = key(account); + let host = match self.accounts.get(&k) { + Some(a) => a.vhost_request.as_ref().map(|r| r.host.clone()), + None => return Err(RegError::Internal), + }; + if host.is_some() { + self.log.append(Event::VhostRequestCleared { account: account.to_string() }).map_err(|_| RegError::Internal)?; + self.accounts.get_mut(&k).unwrap().vhost_request = None; + } + Ok(host) + } + + /// Every account with a pending vhost request, as (account, host). + pub fn vhost_requests(&self) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = self + .accounts + .values() + .filter_map(|a| a.vhost_request.as_ref().map(|r| (a.name.clone(), r.host.clone()))) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out + } + /// Every account that has a vhost, as (account, host, setter). pub fn vhosts(&self) -> Vec<(String, String, String)> { let mut out: Vec<(String, String, String)> = self @@ -2052,6 +2105,16 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.vhost_request = Some(PendingVhost { host, ts }); + } + } + Event::VhostRequestCleared { account } => { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.vhost_request = None; + } + } Event::AccountSuspended { account, by, reason, ts, expires } => { if let Some(a) = accounts.get_mut(&key(&account)) { a.suspension = Some(Suspension { by, reason, ts, expires }); @@ -2330,6 +2393,15 @@ impl Store for Db { fn vhosts(&self) -> Vec { Db::vhosts(self).into_iter().map(|(account, host, setter)| VhostView { account, host, setter }).collect() } + fn request_vhost(&mut self, account: &str, host: &str) -> Result<(), RegError> { + Db::request_vhost(self, account, host) + } + fn take_vhost_request(&mut self, account: &str) -> Result, RegError> { + Db::take_vhost_request(self, account) + } + fn vhost_requests(&self) -> Vec<(String, String)> { + Db::vhost_requests(self) + } fn group_nick(&mut self, nick: &str, account: &str) -> Result<(), RegError> { Db::group_nick(self, nick, account) } @@ -2569,7 +2641,7 @@ mod tests { fn account_conflict_resolves_deterministically() { let alice = |hash: &str, ts: u64, home: &str| Account { name: "alice".into(), password_hash: hash.into(), email: None, - ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, + ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, }; let converge = |first: &Account, second: &Account| { let (mut acc, mut ch, mut gr, mut bo) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new()); @@ -2845,7 +2917,7 @@ mod tests { db.register("alice", "pw", None).unwrap(); let bob = Account { name: "bob".into(), password_hash: "x".into(), email: None, - ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, }; let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, event: Event::AccountRegistered(bob) }; db.ingest(entry).unwrap(); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 4da843f..9d510c9 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1914,7 +1914,7 @@ mod tests { // An earlier claim from another node wins and takes the name over. let winner = db::Account { name: "alice".into(), password_hash: "OTHER".into(), email: None, - ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, }; let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(winner)); e.gossip_ingest(entry).unwrap(); @@ -2686,6 +2686,54 @@ mod tests { assert!(hs(&mut e, "000AAAAAB", "SET alice not a host").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("isn't a valid host"))), "host validated"); } + // HostServ REQUEST -> WAITING -> ACTIVATE assigns and applies the vhost; a + // REJECT clears the request without assigning anything. + #[test] + fn hostserv_request_workflow() { + use fedserv_hostserv::HostServ; + use fedserv_nickserv::NickServ; + let path = std::env::temp_dir().join("fedserv-hsreq.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("boss", "password1", None).unwrap(); + db.register("alice", "password1", None).unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(HostServ { uid: "42SAAAAAG".into() }), + ], + db, + ); + e.set_sid("42S".into()); + let mut opers = std::collections::HashMap::new(); + opers.insert("boss".to_string(), Privs::default().with(fedserv_api::Priv::Admin)); + e.set_opers(opers); + let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() }); + let hs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAG".into(), text: t.into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "realhost".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "alice".into(), host: "realhost".into() }); + ns(&mut e, "000AAAAAB", "IDENTIFY password1"); + ns(&mut e, "000AAAAAV", "IDENTIFY password1"); + let says = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n))); + + // alice requests; the request shows up in WAITING. + hs(&mut e, "000AAAAAV", "REQUEST alice.cool.example"); + assert!(says(&hs(&mut e, "000AAAAAB", "WAITING"), "alice.cool.example"), "request waiting"); + // ACTIVATE assigns + applies it to alice's online session. + let out = hs(&mut e, "000AAAAAB", "ACTIVATE alice"); + assert!(out.iter().any(|a| matches!(a, NetAction::SetHost { uid, host } if uid == "000AAAAAV" && host == "alice.cool.example")), "activated + applied: {out:?}"); + // The request is consumed; WAITING is empty again. + assert!(says(&hs(&mut e, "000AAAAAB", "WAITING"), "No vhost requests"), "request consumed"); + + // A rejected request assigns nothing. + hs(&mut e, "000AAAAAV", "REQUEST other.example"); + assert!(says(&hs(&mut e, "000AAAAAB", "REJECT alice"), "Rejected"), "rejected"); + assert!(says(&hs(&mut e, "000AAAAAB", "WAITING"), "No vhost requests"), "no request left after reject"); + // A non-operator can't approve. + assert!(says(&hs(&mut e, "000AAAAAV", "WAITING"), "Access denied"), "oper-gated"); + } + // StatServ reports per-channel activity (#channel) and the global registry // (SERVER, operators only). #[test] diff --git a/src/grpc.rs b/src/grpc.rs index 14ae173..e07aab7 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -129,6 +129,8 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::AjoinRemoved { .. } | Event::VhostSet { .. } | Event::VhostDeleted { .. } + | Event::VhostRequested { .. } + | Event::VhostRequestCleared { .. } | Event::AccountSuspended { .. } | Event::AccountUnsuspended { .. } | Event::MemoSent { .. } @@ -400,6 +402,7 @@ mod tests { memos: vec![], greet: String::new(), vhost: None, + vhost_request: None, }; let registered = LogEntry::for_test("A", 0, 1, Event::AccountRegistered(acct)); let wire = to_wire(®istered).expect("account registration replicates");