operserv: SWHOIS command — set a persistent extra WHOIS line on an account (event-sourced), pushed to the ircd as a swhois metadata key on set and re-applied on every login; admin to change, operator to view
All checks were successful
CI / check (push) Successful in 7m3s

This commit is contained in:
Jean Chevronnet 2026-08-20 00:02:23 +00:00
parent f1be06d434
commit 7045ca3b2e
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
13 changed files with 179 additions and 6 deletions

View file

@ -2222,6 +2222,9 @@ pub trait Store {
fn set_greet(&mut self, account: &str, greet: &str) -> Result<(), RegError>;
fn set_language(&mut self, account: &str, language: Option<String>) -> Result<(), RegError>;
fn language_of(&self, account: &str) -> Option<String>;
// OperServ SWHOIS: an extra WHOIS line stored on the account (None clears it).
fn set_swhois(&mut self, account: &str, text: Option<String>) -> Result<(), RegError>;
fn swhois(&self, account: &str) -> Option<String>;
fn available_languages(&self) -> Vec<String>;
fn default_language(&self) -> String;
// NickServ SET AVATAR/BIO/PRONOUNS/TIMEZONE/URL: a field of the public profile.

View file

@ -32,6 +32,8 @@ mod stats;
mod svs;
#[path = "info.rs"]
mod info;
#[path = "swhois.rs"]
mod swhois;
#[path = "oper.rs"]
mod oper;
#[path = "session.rs"]
@ -99,6 +101,7 @@ impl Service for OperServ {
Some(cmd) if cmd.eq_ignore_ascii_case("SVSJOIN") => svs::join(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("SVSPART") => svs::part(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("INFO") => info::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("SWHOIS") => swhois::handle(me, from, args, ctx, net, db),
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),
@ -142,6 +145,7 @@ const TOPICS: &[HelpEntry] = &[
HelpEntry { cmd: "SVSJOIN", summary: "force a channel join", detail: "Syntax: \x02SVSJOIN <nick> <#channel> [key]\x02\nForces a user to join a channel." },
HelpEntry { cmd: "SVSPART", summary: "force a channel part", detail: "Syntax: \x02SVSPART <nick> <#channel> [reason]\x02\nForces a user out of a channel." },
HelpEntry { cmd: "INFO", summary: "staff notes on a target", detail: "Syntax: \x02INFO <target> | INFO ADD <target> <note> | INFO DEL <target>\x02\nReads or sets staff notes on an account or channel. (Bulletins are on InfoServ.)" },
HelpEntry { cmd: "SWHOIS", summary: "extra WHOIS line on an account", detail: "Syntax: \x02SWHOIS <account> [text]\x02\nSets an extra line shown in that account's /WHOIS, e.g. \x02SWHOIS reverse is a Network Administrator\x02. It's stored on the account and re-applied on every login. With no text it shows the current line; a bare \x02-\x02 clears it. Reading needs operator; changing needs admin." },
HelpEntry { cmd: "OPER", summary: "runtime operators", detail: "Syntax: \x02OPER ADD <account> <priv[,priv]> [+duration] | OPER DEL <account> | OPER LIST\x02\nGrants or revokes runtime operator privileges: auspex, suspend, admin." },
HelpEntry { cmd: "SESSION", summary: "inspect per-IP sessions", detail: "Syntax: \x02SESSION LIST <min> | SESSION VIEW <ip>\x02\nInspects per-IP session counts." },
HelpEntry { cmd: "EXCEPTION", summary: "session-limit exceptions", detail: "Syntax: \x02EXCEPTION ADD <ip-mask> <limit> [reason] | EXCEPTION DEL <ip-mask> | EXCEPTION LIST\x02\nAdjusts the per-IP session limit for a mask (0 = unlimited)." },

View file

@ -0,0 +1,45 @@
use echo_api::{t, NetView, Priv, Sender, ServiceCtx, Store};
// SWHOIS <account> [text...]: set an extra WHOIS line on an account (e.g.
// "is a Network Administrator"). It's stored on the account and re-applied to the
// ircd every time they log in, so it survives reconnects. With no text it shows the
// current line; a bare "-" clears it. Reading needs operator; changing needs admin.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
let Some(target) = args.get(1).copied() else {
ctx.notice(me, from.uid, "Syntax: SWHOIS <account> [text] — no text shows the line, a bare \x02-\x02 clears it.");
return;
};
let Some(account) = db.resolve_account(target).map(str::to_string) else {
ctx.notice(me, from.uid, t!(ctx, "There's no {kind} \x02{target}\x02.", kind = "account", target = target));
return;
};
let rest = args.get(2..).unwrap_or(&[]).join(" ");
// No argument: just show what's currently set (operator is enough).
if rest.trim().is_empty() {
match db.swhois(&account) {
Some(line) => ctx.notice(me, from.uid, format!("SWHOIS on \x02{account}\x02: {line}")),
None => ctx.notice(me, from.uid, format!("\x02{account}\x02 has no SWHOIS line.")),
}
return;
}
// Changing it sets a public WHOIS title, so require the admin privilege.
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — changing a SWHOIS needs the \x02admin\x02 privilege.");
return;
}
let value = if rest.trim() == "-" { None } else { Some(rest) };
if db.set_swhois(&account, value.clone()).is_err() {
ctx.notice(me, from.uid, format!("Couldn't update \x02{account}\x02."));
return;
}
// Apply it live to every session currently logged into the account (an empty
// value clears the line on the ircd).
let wire = value.clone().unwrap_or_default();
for uid in net.uids_logged_into(&account) {
ctx.metadata(&uid, "swhois", &wire);
}
match &value {
Some(line) => ctx.notice(me, from.uid, format!("SWHOIS on \x02{account}\x02 set to: {line}")),
None => ctx.notice(me, from.uid, format!("SWHOIS on \x02{account}\x02 cleared.")),
}
}

View file

@ -31,6 +31,7 @@ impl Db {
noexpire: false,
expiry_warned: false,
oper_note: None,
swhois: None,
};
self.log.append(Event::AccountRegistered(Box::new(account.clone()))).map_err(|_| RegError::Internal)?;
self.accounts.insert(key(name), account);
@ -88,6 +89,7 @@ impl Db {
noexpire: false,
expiry_warned: false,
oper_note: None,
swhois: None,
};
self.log.append(Event::AccountRegistered(Box::new(account.clone()))).map_err(|_| RegError::Internal)?;
self.accounts.insert(key(name), account);
@ -552,6 +554,22 @@ impl Db {
self.accounts.get(&key(account)).and_then(|a| a.language.clone())
}
/// Set (or clear, with None) `account`'s extra WHOIS line (OperServ SWHOIS).
pub fn set_swhois(&mut self, account: &str, text: Option<String>) -> Result<(), RegError> {
let k = key(account);
if !self.accounts.contains_key(&k) {
return Err(RegError::Internal);
}
self.log.append(Event::AccountSwhoisSet { account: account.to_string(), text: text.clone() }).map_err(|_| RegError::Internal)?;
self.accounts.get_mut(&k).unwrap().swhois = text;
Ok(())
}
/// `account`'s extra WHOIS line, if it has one.
pub fn swhois(&self, account: &str) -> Option<String> {
self.accounts.get(&key(account)).and_then(|a| a.swhois.clone())
}
/// Set (or clear, with None) a field of `account`'s public profile.
pub fn set_profile(&mut self, account: &str, field: ProfileField, value: Option<String>) -> Result<(), RegError> {
let k = key(account);

View file

@ -16,6 +16,7 @@ pub enum Event {
AccountEmailSet { account: String, email: Option<String> },
AccountGreetSet { account: String, greet: String },
AccountLanguageSet { account: String, language: Option<String> },
AccountSwhoisSet { account: String, text: Option<String> },
AccountProfileSet { account: String, field: String, value: Option<String> },
AccountAutoOpSet { account: String, on: bool },
AccountKillSet { account: String, on: bool },
@ -190,6 +191,7 @@ impl Event {
| Event::AccountEmailSet { .. }
| Event::AccountGreetSet { .. }
| Event::AccountLanguageSet { .. }
| Event::AccountSwhoisSet { .. }
| Event::AccountProfileSet { .. }
| Event::AccountAutoOpSet { .. }
| Event::AccountKillSet { .. }
@ -369,6 +371,11 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
a.language = language;
}
}
Event::AccountSwhoisSet { account, text } => {
if let Some(a) = accounts.get_mut(&key(&account)) {
a.swhois = text;
}
}
Event::AccountProfileSet { account, field, value } => {
if let (Some(a), Some(f)) = (accounts.get_mut(&key(&account)), ProfileField::parse(&field)) {
a.profile.set(f, value);

View file

@ -139,6 +139,9 @@ pub struct Account {
// A staff note (OperServ INFO), shown only to operators.
#[serde(default)]
pub oper_note: Option<String>,
// An extra WHOIS line (OperServ SWHOIS), re-applied to the ircd on each login.
#[serde(default)]
pub swhois: Option<String>,
}
// A requested vhost awaiting approval.

View file

@ -111,6 +111,12 @@ impl Store for Db {
fn language_of(&self, account: &str) -> Option<String> {
Db::language_of(self, account)
}
fn set_swhois(&mut self, account: &str, text: Option<String>) -> Result<(), RegError> {
Db::set_swhois(self, account, text)
}
fn swhois(&self, account: &str) -> Option<String> {
Db::swhois(self, account)
}
fn set_profile(&mut self, account: &str, field: ProfileField, value: Option<String>) -> Result<(), RegError> {
Db::set_profile(self, account, field, value)
}

View file

@ -32,7 +32,7 @@
// which of the two competing registrations we're looking at.
let alice = |tag: &str, ts: u64, home: &str| Account {
name: "alice".into(), email: Some(tag.into()),
ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, profile: Default::default(), vhost: None, vhost_request: None, last_seen: ts, noexpire: false, expiry_warned: false, oper_note: None,
ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, profile: Default::default(), vhost: None, vhost_request: None, last_seen: ts, noexpire: false, expiry_warned: false, oper_note: None, swhois: None,
};
let converge = |first: &Account, second: &Account| {
let (mut acc, mut ch, mut gr, mut bo, mut hc, mut nd) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), NetData::default());
@ -82,7 +82,7 @@
ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true,
memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false,
hide_status: false, snotice: false, language: None, profile: Default::default(), vhost: None, vhost_request: None, last_seen: ts, noexpire: false,
expiry_warned: false, oper_note: None,
expiry_warned: false, oper_note: None, swhois: None,
};
let reg = |origin: &str, a: Account| LogEntry::for_test(origin, 0, 1, Event::AccountRegistered(Box::new(a)));
@ -463,7 +463,7 @@
db.register("alice", "pw", None).unwrap();
let bob = Account {
name: "bob".into(), email: None,
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, profile: Default::default(), vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None,
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, profile: Default::default(), vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None, swhois: None,
};
let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, epoch: 0, sig: None, event: Event::AccountRegistered(Box::new(bob)) };
db.ingest(entry).unwrap();
@ -575,7 +575,7 @@
suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true,
memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false,
hide_status: false, snotice: false, language: None, profile: Default::default(), vhost: None, vhost_request: None, last_seen: 1,
noexpire: false, expiry_warned: false, oper_note: None,
noexpire: false, expiry_warned: false, oper_note: None, swhois: None,
}))).unwrap();
}
// Reopen so the imported account is live in memory (as after a restart).

View file

@ -629,6 +629,10 @@ impl Engine {
out.push(NetAction::Metadata { target: uid.to_string(), key: field.meta_key().to_string(), value: v });
}
}
// Re-apply the account's OperServ SWHOIS line (per-connection on the ircd).
if let Some(swhois) = self.db.swhois(account) {
out.push(NetAction::Metadata { target: uid.to_string(), key: "swhois".to_string(), value: swhois });
}
let unread = self.db.unread_memos(account);
if unread > 0 && self.db.memo_notify_on(account) {
if let Some(ns) = &self.nick_service {
@ -2086,6 +2090,10 @@ fn audit_summary(event: &db::Event) -> Option<String> {
Some(_) => format!("set the email on \x02{account}\x02"),
None => format!("cleared the email on \x02{account}\x02"),
},
AccountSwhoisSet { account, text } => match text {
Some(t) => format!("set the SWHOIS on \x02{account}\x02 to \x02{t}\x02"),
None => format!("cleared the SWHOIS on \x02{account}\x02"),
},
CertAdded { account, fp } => format!("added cert \x02{fp}\x02 to \x02{account}\x02"),
CertRemoved { account, fp } => format!("removed cert \x02{fp}\x02 from \x02{account}\x02"),
AccountSuspended { account, reason, .. } => format!("suspended account \x02{account}\x02 ({reason})"),

View file

@ -437,6 +437,10 @@ impl Engine {
ctx.metadata(&uid, field.meta_key(), &v);
}
}
// Re-apply the account's OperServ SWHOIS line (per-connection on the ircd).
if let Some(swhois) = self.db.swhois(&account) {
ctx.metadata(&uid, "swhois", &swhois);
}
let unread = self.db.unread_memos(&account);
if unread > 0 && self.db.memo_notify_on(&account) {
ctx.notice(&agent, &uid, echo_api::render_plural(&lang, unread as u64, "You have \x02{unread}\x02 new memo. Read it with \x02/msg MemoServ READ NEW\x02.", "You have \x02{unread}\x02 new memos. Read them with \x02/msg MemoServ READ NEW\x02.", &[("unread", unread.to_string())]));

View file

@ -1013,7 +1013,7 @@
// An earlier claim from another node wins and takes the name over.
let winner = db::Account {
name: "alice".into(), email: None,
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, profile: Default::default(), vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None,
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, profile: Default::default(), vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None, swhois: None,
};
let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(Box::new(winner)));
e.gossip_ingest(entry).unwrap();
@ -4165,6 +4165,78 @@
assert_eq!(again, 0, "warning isn't repeated while still idle");
}
// OperServ SWHOIS: an admin sets an extra WHOIS line on an account; it's applied
// live (swhois metadata to the online session), persisted in the log so it
// re-applies on the next login, and cleared with a bare "-".
#[test]
fn operserv_swhois_set_apply_persist_clear() {
use echo_operserv::OperServ;
let path = std::env::temp_dir().join("echo-swhois.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("target", "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(echo_api::Priv::Admin));
e.set_opers(opers);
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { msgid: None, from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
let swhois_meta = |out: &[NetAction], uid: &str, val: &str| out.iter().any(|a| matches!(a, NetAction::Metadata { target, key, value } if target == uid && key == "swhois" && value == val));
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "0.0.0.0".into() });
e.handle(NetEvent::Privmsg { msgid: None, from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
e.handle(NetEvent::UserConnect { uid: "000AAAAAT".into(), nick: "target".into(), host: "h".into(), ip: "0.0.0.0".into() });
e.handle(NetEvent::Privmsg { msgid: None, from: "000AAAAAT".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
// A non-oper can't use OperServ at all.
let denied = os(&mut e, "000AAAAAT", "SWHOIS target is a Network Administrator");
assert!(denied.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused: {denied:?}");
assert!(!swhois_meta(&denied, "000AAAAAT", "is a Network Administrator"), "no swhois from a non-oper");
// Admin sets it: applied live to the target's online session + persisted.
let out = os(&mut e, "000AAAAAS", "SWHOIS target is a Network Administrator");
assert!(swhois_meta(&out, "000AAAAAT", "is a Network Administrator"), "swhois pushed to the online session: {out:?}");
assert_eq!(e.db.swhois("target").as_deref(), Some("is a Network Administrator"), "swhois persisted on the account");
// It re-applies when the account logs in again (a fresh session).
e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "other".into(), host: "h".into(), ip: "0.0.0.0".into() });
let relog = e.handle(NetEvent::Privmsg { msgid: None, from: "000AAAAAV".into(), to: "42SAAAAAA".into(), text: "IDENTIFY target password1".into() });
assert!(swhois_meta(&relog, "000AAAAAV", "is a Network Administrator"), "swhois re-applied on login: {relog:?}");
// Survives a full reopen of the event log.
drop(e);
let db2 = Db::open(&path, "42S");
assert_eq!(db2.swhois("target").as_deref(), Some("is a Network Administrator"), "swhois survives log replay");
// A bare "-" clears it: empty metadata to the online session + gone from the account.
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() }),
],
db2,
);
e.set_sid("42S".into());
let mut opers = std::collections::HashMap::new();
opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin));
e.set_opers(opers);
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "0.0.0.0".into() });
e.handle(NetEvent::Privmsg { msgid: None, from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
e.handle(NetEvent::UserConnect { uid: "000AAAAAT".into(), nick: "target".into(), host: "h".into(), ip: "0.0.0.0".into() });
e.handle(NetEvent::Privmsg { msgid: None, from: "000AAAAAT".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
let cleared = os(&mut e, "000AAAAAS", "SWHOIS target -");
assert!(swhois_meta(&cleared, "000AAAAAT", ""), "empty swhois metadata clears it live: {cleared:?}");
assert_eq!(e.db.swhois("target"), None, "swhois removed from the account");
}
// OperServ SQLINE (nick bans), GLOBAL (announce to all), and KILL (disconnect
// a user): the Q-line, the $* broadcast, and the KILL all reach the ircd, and
// each is admin-gated.

View file

@ -214,7 +214,8 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
| Event::OperRevoked { .. }
| Event::SessionExceptionAdded { .. }
| Event::SessionExceptionRemoved { .. }
| Event::AccountProfileSet { .. } => return None,
| Event::AccountProfileSet { .. }
| Event::AccountSwhoisSet { .. } => return None,
};
Some(ReplicationEvent { origin: entry.origin().to_string(), seq: entry.seq(), lamport: entry.lamport(), kind: Some(kind) })
}
@ -538,6 +539,7 @@ mod tests {
noexpire: false,
expiry_warned: false,
oper_note: None,
swhois: None,
};
let registered = LogEntry::for_test("A", 0, 1, Event::AccountRegistered(Box::new(acct)));
let wire = to_wire(&registered).expect("account registration replicates");

View file

@ -212,6 +212,7 @@ pub fn import_anope(anope_path: &str, out_path: &str, node: &str) -> std::io::Re
noexpire: false,
expiry_warned: false,
oper_note: None,
swhois: None,
};
db.migrate_append(Event::AccountRegistered(Box::new(account)))?;
sum.accounts += 1;