From 898461d1c2877fcb43ff9cc68bbb82c1435d1388 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 22:04:16 +0000 Subject: [PATCH] HostServ: ident@host vhosts (CHGIDENT) A vhost may be ident@host, not just host: apply_vhost splits it and sends CHGIDENT for the ident plus CHGHOST for the host. valid_vhost validates the optional ident part; ON/SET/ACTIVATE/identify all route through apply_vhost, so a bare host still works unchanged. New SetIdent action. --- api/src/lib.rs | 14 ++++++++++++++ hostserv/src/approve.rs | 2 +- hostserv/src/lib.rs | 15 ++++++++++++--- hostserv/src/on.rs | 2 +- hostserv/src/set.rs | 2 +- inspircd/src/lib.rs | 8 +++++++- nickserv/src/identify.rs | 2 +- src/engine/mod.rs | 28 ++++++++++++++++++++++++++++ 8 files changed, 65 insertions(+), 8 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 95a22ba..3bccd79 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -66,6 +66,8 @@ pub enum NetAction { ForceNick { uid: String, nick: String }, // Set a user's displayed host (a vhost), or restore it. SetHost { uid: String, host: String }, + // Set a user's displayed ident/username (the `ident@` part of a vhost). + SetIdent { uid: String, ident: String }, // Force a user into a channel (SVSJOIN), e.g. applying an account's auto-join // list on identify. `key` is empty for keyless channels. ForceJoin { uid: String, channel: String, key: String }, @@ -265,6 +267,18 @@ impl ServiceCtx { }); } + // Apply a vhost spec to a user: `ident@host` sets both the ident and host, + // a bare `host` just the host. + pub fn apply_vhost(&mut self, uid: &str, spec: &str) { + match spec.split_once('@') { + Some((ident, host)) => { + self.actions.push(NetAction::SetIdent { uid: uid.to_string(), ident: ident.to_string() }); + self.set_host(uid, host); + } + None => self.set_host(uid, spec), + } + } + // Force a user into a channel (SVSJOIN), e.g. an account's auto-join list. pub fn force_join(&mut self, uid: &str, channel: &str, key: &str) { self.actions.push(NetAction::ForceJoin { diff --git a/hostserv/src/approve.rs b/hostserv/src/approve.rs index 10237a0..1624b72 100644 --- a/hostserv/src/approve.rs +++ b/hostserv/src/approve.rs @@ -29,7 +29,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: match db.set_vhost(account, &host, from.nick) { Ok(()) => { for uid in net.uids_logged_into(account) { - ctx.set_host(&uid, &host); + ctx.apply_vhost(&uid, &host); } ctx.notice(me, from.uid, format!("Activated vhost \x02{host}\x02 for \x02{account}\x02.")); } diff --git a/hostserv/src/lib.rs b/hostserv/src/lib.rs index 811858b..dea9015 100644 --- a/hostserv/src/lib.rs +++ b/hostserv/src/lib.rs @@ -64,9 +64,18 @@ fn require_oper(me: &str, from: &Sender, ctx: &mut ServiceCtx) -> bool { false } -// Whether `host` is a syntactically valid vhost: a hostname of dot-separated -// labels using letters, digits and hyphens, not too long. -pub(crate) fn valid_vhost(host: &str) -> bool { +// Whether `spec` is a valid vhost: an optional `ident@` (letters, digits, a few +// punctuation) followed by a hostname of dot-separated alphanumeric/hyphen labels. +pub(crate) fn valid_vhost(spec: &str) -> bool { + let (ident, host) = match spec.split_once('@') { + Some((i, h)) => (Some(i), h), + None => (None, spec), + }; + if let Some(i) = ident { + if i.is_empty() || i.len() > 12 || !i.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') { + return false; + } + } if host.is_empty() || host.len() > 64 || !host.contains('.') { return false; } diff --git a/hostserv/src/on.rs b/hostserv/src/on.rs index e1b8ecc..e83b861 100644 --- a/hostserv/src/on.rs +++ b/hostserv/src/on.rs @@ -8,7 +8,7 @@ pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) { }; match db.vhost(account) { Some(v) => { - ctx.set_host(from.uid, &v.host); + ctx.apply_vhost(from.uid, &v.host); ctx.notice(me, from.uid, format!("Your vhost \x02{}\x02 is now active.", v.host)); } None => ctx.notice(me, from.uid, "You have no vhost assigned."), diff --git a/hostserv/src/set.rs b/hostserv/src/set.rs index b9d930a..f789d9d 100644 --- a/hostserv/src/set.rs +++ b/hostserv/src/set.rs @@ -21,7 +21,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: match db.set_vhost(account, host, from.nick) { Ok(()) => { for uid in net.uids_logged_into(account) { - ctx.set_host(&uid, host); + ctx.apply_vhost(&uid, host); } ctx.notice(me, from.uid, format!("Vhost \x02{host}\x02 assigned to \x02{account}\x02.")); } diff --git a/inspircd/src/lib.rs b/inspircd/src/lib.rs index 299b895..395bb7d 100644 --- a/inspircd/src/lib.rs +++ b/inspircd/src/lib.rs @@ -275,6 +275,10 @@ impl Protocol for InspIrcd { let target = uid.get(..3).unwrap_or(uid.as_str()); vec![self.from_us(format!("ENCAP {} CHGHOST {} {}", target, uid, host))] } + NetAction::SetIdent { uid, ident } => { + let target = uid.get(..3).unwrap_or(uid.as_str()); + vec![self.from_us(format!("ENCAP {} CHGIDENT {} {}", target, uid, ident))] + } NetAction::ForceNick { uid, nick } => { let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts); vec![self.from_us(format!("SVSNICK {} {} {}", uid, nick, now))] @@ -453,11 +457,13 @@ mod tests { assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelVoice { uid, voice: true, .. } if uid == "0IRAAAAAD")), "join voice: {ev:?}"); } - // A vhost is set by ENCAP'ing the target's own server with CHGHOST. + // A vhost is set by ENCAP'ing the target's own server with CHGHOST / CHGIDENT. #[test] fn serializes_set_host() { let lines = proto().serialize(&NetAction::SetHost { uid: "0IRAAAAAB".into(), host: "cool.example".into() }); assert_eq!(lines, vec![":42S ENCAP 0IR CHGHOST 0IRAAAAAB cool.example".to_string()]); + let lines = proto().serialize(&NetAction::SetIdent { uid: "0IRAAAAAB".into(), ident: "cool".into() }); + assert_eq!(lines, vec![":42S ENCAP 0IR CHGIDENT 0IRAAAAAB cool".to_string()]); } #[test] diff --git a/nickserv/src/identify.rs b/nickserv/src/identify.rs index 9d4d1d2..d41be98 100644 --- a/nickserv/src/identify.rs +++ b/nickserv/src/identify.rs @@ -61,7 +61,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: } // Apply the account's vhost (HostServ), if it has one. if let Some(v) = db.vhost(&account) { - ctx.set_host(from.uid, &v.host); + ctx.apply_vhost(from.uid, &v.host); } // Let them know about waiting memos. let unread = db.unread_memos(&account); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 9d510c9..37834d8 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -2686,6 +2686,34 @@ 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"); } + // An ident@host vhost applies both the ident (CHGIDENT) and the host. + #[test] + fn hostserv_ident_at_host() { + use fedserv_hostserv::HostServ; + use fedserv_nickserv::NickServ; + let path = std::env::temp_dir().join("fedserv-hsident.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(); + db.set_vhost("alice", "web@cloak.example", "system").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 ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "alice".into(), host: "realhost".into() }); + + let out = ns(&mut e, "000AAAAAV", "IDENTIFY password1"); + assert!(out.iter().any(|a| matches!(a, NetAction::SetIdent { uid, ident } if uid == "000AAAAAV" && ident == "web")), "ident set: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::SetHost { uid, host } if uid == "000AAAAAV" && host == "cloak.example")), "host set"); + } + // HostServ REQUEST -> WAITING -> ACTIVATE assigns and applies the vhost; a // REJECT clears the request without assigning anything. #[test]