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.
This commit is contained in:
Jean Chevronnet 2026-07-13 22:04:16 +00:00
parent 1edf250952
commit 898461d1c2
No known key found for this signature in database
8 changed files with 65 additions and 8 deletions

View file

@ -66,6 +66,8 @@ pub enum NetAction {
ForceNick { uid: String, nick: String }, ForceNick { uid: String, nick: String },
// Set a user's displayed host (a vhost), or restore it. // Set a user's displayed host (a vhost), or restore it.
SetHost { uid: String, host: String }, 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 // Force a user into a channel (SVSJOIN), e.g. applying an account's auto-join
// list on identify. `key` is empty for keyless channels. // list on identify. `key` is empty for keyless channels.
ForceJoin { uid: String, channel: String, key: String }, 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. // 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) { pub fn force_join(&mut self, uid: &str, channel: &str, key: &str) {
self.actions.push(NetAction::ForceJoin { self.actions.push(NetAction::ForceJoin {

View file

@ -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) { match db.set_vhost(account, &host, from.nick) {
Ok(()) => { Ok(()) => {
for uid in net.uids_logged_into(account) { 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.")); ctx.notice(me, from.uid, format!("Activated vhost \x02{host}\x02 for \x02{account}\x02."));
} }

View file

@ -64,9 +64,18 @@ fn require_oper(me: &str, from: &Sender, ctx: &mut ServiceCtx) -> bool {
false false
} }
// Whether `host` is a syntactically valid vhost: a hostname of dot-separated // Whether `spec` is a valid vhost: an optional `ident@` (letters, digits, a few
// labels using letters, digits and hyphens, not too long. // punctuation) followed by a hostname of dot-separated alphanumeric/hyphen labels.
pub(crate) fn valid_vhost(host: &str) -> bool { 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('.') { if host.is_empty() || host.len() > 64 || !host.contains('.') {
return false; return false;
} }

View file

@ -8,7 +8,7 @@ pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
}; };
match db.vhost(account) { match db.vhost(account) {
Some(v) => { 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)); 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."), None => ctx.notice(me, from.uid, "You have no vhost assigned."),

View file

@ -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) { match db.set_vhost(account, host, from.nick) {
Ok(()) => { Ok(()) => {
for uid in net.uids_logged_into(account) { 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.")); ctx.notice(me, from.uid, format!("Vhost \x02{host}\x02 assigned to \x02{account}\x02."));
} }

View file

@ -275,6 +275,10 @@ impl Protocol for InspIrcd {
let target = uid.get(..3).unwrap_or(uid.as_str()); let target = uid.get(..3).unwrap_or(uid.as_str());
vec![self.from_us(format!("ENCAP {} CHGHOST {} {}", target, uid, host))] 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 } => { NetAction::ForceNick { uid, nick } => {
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts); 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))] 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:?}"); 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] #[test]
fn serializes_set_host() { fn serializes_set_host() {
let lines = proto().serialize(&NetAction::SetHost { uid: "0IRAAAAAB".into(), host: "cool.example".into() }); 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()]); 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] #[test]

View file

@ -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. // Apply the account's vhost (HostServ), if it has one.
if let Some(v) = db.vhost(&account) { 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 them know about waiting memos.
let unread = db.unread_memos(&account); let unread = db.unread_memos(&account);

View file

@ -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"); 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 // HostServ REQUEST -> WAITING -> ACTIVATE assigns and applies the vhost; a
// REJECT clears the request without assigning anything. // REJECT clears the request without assigning anything.
#[test] #[test]