modules: split services and the ircd link into external crates

nickserv, chanserv and the InspIRCd protocol move out of the binary into their
own workspace crates (fedserv-nickserv, fedserv-chanserv, fedserv-inspircd),
each depending only on fedserv-api. human_time and the branded account emails
move into the SDK crate so a module needs nothing from core; the engine keeps
its own inherent methods and builds emails via fedserv-api too. The bin now
constructs each module from its crate instead of an in-tree #[path] include.
Proves the SDK is self-sufficient: a third-party module is the same shape.
This commit is contained in:
Jean Chevronnet 2026-07-13 01:33:56 +00:00
parent 8ed1a9ab70
commit 596630df53
No known key found for this signature in database
52 changed files with 197 additions and 162 deletions

27
nickserv/src/alist.rs Normal file
View file

@ -0,0 +1,27 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// ALIST: list the channels the sender's account founds or has access on.
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
return;
};
let mut rows: Vec<(String, &str)> = Vec::new();
for c in db.channels() {
if c.founder.eq_ignore_ascii_case(account) {
rows.push((c.name.clone(), "founder"));
} else if let Some(a) = c.access.iter().find(|a| a.account.eq_ignore_ascii_case(account)) {
rows.push((c.name.clone(), if a.level == "voice" { "voice" } else { "op" }));
}
}
if rows.is_empty() {
ctx.notice(me, from.uid, "You have access on no channels.");
return;
}
rows.sort_by(|a, b| a.0.cmp(&b.0));
ctx.notice(me, from.uid, format!("Channels you have access on ({}):", rows.len()));
for (chan, role) in rows {
ctx.notice(me, from.uid, format!(" \x02{chan}\x02 ({role})"));
}
}

59
nickserv/src/cert.rs Normal file
View file

@ -0,0 +1,59 @@
use fedserv_api::{CertError, Store};
use fedserv_api::{Sender, ServiceCtx};
// CERT ADD|DEL|LIST <password> [fingerprint]: manage the TLS certificate
// fingerprints that may log in to your account via SASL EXTERNAL. Each
// subcommand is password-gated, so it needs no identified-session state.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let auth = |db: &dyn Store, password: &str| db.authenticate(from.nick, password).map(str::to_string);
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("LIST") => {
let Some(password) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: CERT LIST <password>");
return;
};
let Some(account) = auth(db, password) else {
ctx.notice(me, from.uid, "Invalid password.");
return;
};
let fps = db.certfps(&account);
if fps.is_empty() {
ctx.notice(me, from.uid, "No certificate fingerprints are registered to your account.");
} else {
ctx.notice(me, from.uid, format!("Registered fingerprints: {}", fps.join(", ")));
}
}
Some("ADD") => {
let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else {
ctx.notice(me, from.uid, "Syntax: CERT ADD <password> <fingerprint>");
return;
};
let Some(account) = auth(db, password) else {
ctx.notice(me, from.uid, "Invalid password.");
return;
};
match db.certfp_add(&account, fp) {
Ok(()) => ctx.notice(me, from.uid, format!("Added fingerprint \x02{fp}\x02. You can now log in with SASL EXTERNAL.")),
Err(CertError::InUse) => ctx.notice(me, from.uid, "That fingerprint is already registered."),
Err(CertError::Invalid) => ctx.notice(me, from.uid, "That does not look like a certificate fingerprint."),
Err(CertError::NoAccount | CertError::Internal) => ctx.notice(me, from.uid, "Could not add the fingerprint, please try again later."),
}
}
Some("DEL") | Some("REMOVE") => {
let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else {
ctx.notice(me, from.uid, "Syntax: CERT DEL <password> <fingerprint>");
return;
};
let Some(account) = auth(db, password) else {
ctx.notice(me, from.uid, "Invalid password.");
return;
};
match db.certfp_del(&account, fp) {
Ok(true) => ctx.notice(me, from.uid, format!("Removed fingerprint \x02{fp}\x02.")),
Ok(false) => ctx.notice(me, from.uid, "No such fingerprint is registered to your account."),
Err(_) => ctx.notice(me, from.uid, "Could not remove the fingerprint, please try again later."),
}
}
_ => ctx.notice(me, from.uid, "Syntax: CERT ADD|DEL|LIST <password> [fingerprint]"),
}
}

26
nickserv/src/confirm.rs Normal file
View file

@ -0,0 +1,26 @@
use fedserv_api::{CodeKind, Store};
use fedserv_api::{Sender, ServiceCtx};
// CONFIRM <code>: confirm your account's email with the code you were emailed.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(&code) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: CONFIRM <code>");
return;
};
let Some(account) = from.account.map(str::to_string).or_else(|| db.resolve_account(from.nick).map(str::to_string)) else {
ctx.notice(me, from.uid, "You don't have an account to confirm.");
return;
};
if db.is_verified(&account) {
ctx.notice(me, from.uid, format!("\x02{account}\x02 is already confirmed."));
return;
}
if !db.take_code(&account, CodeKind::Confirm, code) {
ctx.notice(me, from.uid, "Invalid or expired confirmation code.");
return;
}
match db.verify_account(&account) {
Ok(()) => ctx.notice(me, from.uid, format!("\x02{account}\x02 is now confirmed. Thanks!")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

33
nickserv/src/drop.rs Normal file
View file

@ -0,0 +1,33 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// DROP <password>: delete your account. Re-authenticates as confirmation, releases
// and drops the channels you found, and logs you out.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
return;
};
let Some(&password) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: DROP <password>");
return;
};
if db.authenticate(account, password).is_none() {
ctx.notice(me, from.uid, "Invalid password.");
return;
}
let channels = db.channels_owned_by(account);
for chan in &channels {
let _ = db.drop_channel(chan);
ctx.channel_mode("", chan, "-r"); // server-sourced: release the registered mode
}
let _ = db.drop_account(account);
for uid in net.uids_logged_into(account) {
ctx.logout(&uid);
}
ctx.notice(me, from.uid, format!("Your account \x02{account}\x02 has been dropped."));
if !channels.is_empty() {
ctx.notice(me, from.uid, format!("Channels released: {}.", channels.join(", ")));
}
}

33
nickserv/src/ghost.rs Normal file
View file

@ -0,0 +1,33 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// GHOST/RECOVER <nick> [password]: rename off a session using a nick you own,
// either by being identified to its account or giving that account's password.
pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
let Some(&target) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: GHOST <nick> [password]");
return;
};
let Some(account) = db.resolve_account(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
return;
};
let owns = from.account == Some(account.as_str()) || args.get(2).is_some_and(|pw| db.authenticate(target, pw).is_some());
if !owns {
ctx.notice(me, from.uid, format!("Access denied. Identify to \x02{account}\x02 or give its password."));
return;
}
let Some(ghost) = net.uid_by_nick(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("Nobody is using \x02{target}\x02."));
return;
};
if ghost == from.uid {
ctx.notice(me, from.uid, "That's you.");
return;
}
let guest = format!("{guest_nick}{guest_seq}");
*guest_seq = guest_seq.wrapping_add(1);
ctx.force_nick(&ghost, &guest);
ctx.notice(me, from.uid, format!("\x02{target}\x02 has been freed."));
}

17
nickserv/src/glist.rs Normal file
View file

@ -0,0 +1,17 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// GLIST: list the nicks grouped to your account.
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
return;
};
let mut nicks = db.grouped_nicks(account);
nicks.sort();
ctx.notice(me, from.uid, format!("Nicks grouped to \x02{account}\x02:"));
ctx.notice(me, from.uid, format!(" \x02{account}\x02 (main)"));
for n in nicks {
ctx.notice(me, from.uid, format!(" \x02{n}\x02"));
}
}

23
nickserv/src/group.rs Normal file
View file

@ -0,0 +1,23 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// GROUP <account> <password>: link your current nick to an existing account, so
// you can identify to it under this nick too.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let (Some(&account), Some(&password)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: GROUP <account> <password>");
return;
};
let Some(canonical) = db.authenticate(account, password).map(str::to_string) else {
ctx.notice(me, from.uid, "Invalid account or password.");
return;
};
if db.account(from.nick).is_some() {
ctx.notice(me, from.uid, format!("\x02{}\x02 is itself a registered account.", from.nick));
return;
}
match db.group_nick(from.nick, &canonical) {
Ok(()) => ctx.notice(me, from.uid, format!("Your nick \x02{}\x02 is now grouped to \x02{canonical}\x02.", from.nick)),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

33
nickserv/src/identify.rs Normal file
View file

@ -0,0 +1,33 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// IDENTIFY [account] <password>: log in. The account defaults to the current
// nick, so both the bare-password and account+password forms work.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) {
let (account_name, password) = match (args.get(1), args.get(2)) {
(Some(account), Some(password)) => (*account, *password),
(Some(password), None) => (from.nick, *password),
_ => {
ctx.notice(me, from.uid, "Syntax: IDENTIFY [account] <password>");
return;
}
};
// Distinguish an unregistered account from a wrong password.
if !db.exists(account_name) {
ctx.notice(me, from.uid, format!("\x02{account_name}\x02 isn't registered."));
return;
}
match db.authenticate(account_name, password) {
Some(account) => {
// Already identified to this account: don't re-fire the login.
if from.account == Some(account) {
ctx.notice(me, from.uid, format!("You're already identified as \x02{}\x02.", account));
return;
}
let account = account.to_string();
ctx.login(from.uid, &account);
ctx.notice(me, from.uid, format!("You're now identified as \x02{}\x02. Welcome back!", account));
}
None => ctx.notice(me, from.uid, "Invalid password. Please try again."),
}
}

21
nickserv/src/info.rs Normal file
View file

@ -0,0 +1,21 @@
use fedserv_api::{human_time, Store};
use fedserv_api::{Sender, ServiceCtx};
// INFO [account]: show an account's registration details. The email is shown
// only to the account's own owner.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) {
let name = args.get(1).copied().unwrap_or(from.nick);
let Some(acct) = db.account(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
ctx.notice(me, from.uid, format!("Information for \x02{}\x02:", acct.name));
ctx.notice(me, from.uid, format!(" Registered : {}", human_time(acct.ts)));
if from.account == Some(acct.name.as_str()) {
match &acct.email {
Some(email) if acct.verified => ctx.notice(me, from.uid, format!(" Email : {email}")),
Some(email) => ctx.notice(me, from.uid, format!(" Email : {email} (unconfirmed)")),
None => ctx.notice(me, from.uid, " Email : (none set)"),
}
}
}

78
nickserv/src/lib.rs Normal file
View file

@ -0,0 +1,78 @@
use fedserv_api::Store;
use fedserv_api::{Sender, Service, ServiceCtx};
use fedserv_api::NetView;
#[path = "register.rs"]
mod register;
#[path = "identify.rs"]
mod identify;
#[path = "logout.rs"]
mod logout;
#[path = "cert.rs"]
mod cert;
#[path = "info.rs"]
mod info;
#[path = "alist.rs"]
mod alist;
#[path = "set.rs"]
mod set;
#[path = "drop.rs"]
mod drop;
#[path = "group.rs"]
mod group;
#[path = "glist.rs"]
mod glist;
#[path = "ungroup.rs"]
mod ungroup;
#[path = "ghost.rs"]
mod ghost;
#[path = "resetpass.rs"]
mod resetpass;
#[path = "confirm.rs"]
mod confirm;
pub struct NickServ {
pub uid: String,
// Nick prefix assigned on LOGOUT (default "Guest"); a per-session sequence is
// appended so successive guests don't collide within a run.
pub guest_nick: String,
pub guest_seq: u32,
}
impl Service for NickServ {
fn nick(&self) -> &str {
"NickServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Nickname Services"
}
fn manages_accounts(&self) -> bool {
true
}
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
let me = self.uid.as_str();
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
Some("REGISTER") => register::handle(me, from, args, ctx),
Some("IDENTIFY") | Some("ID") => identify::handle(me, from, args, ctx, db),
Some("LOGOUT") | Some("LOGOFF") => logout::handle(me, &self.guest_nick, &mut self.guest_seq, from, ctx),
Some("CERT") => cert::handle(me, from, args, ctx, db),
Some("INFO") => info::handle(me, from, args, ctx, db),
Some("ALIST") => alist::handle(me, from, ctx, db),
Some("SET") => set::handle(me, from, args, ctx, db),
Some("DROP") => drop::handle(me, from, args, ctx, net, db),
Some("GROUP") => group::handle(me, from, args, ctx, db),
Some("GLIST") => glist::handle(me, from, ctx, db),
Some("UNGROUP") => ungroup::handle(me, from, args, ctx, db),
Some("GHOST") | Some("RECOVER") => ghost::handle(me, &self.guest_nick, &mut self.guest_seq, from, args, ctx, net, db),
Some("RESETPASS") => resetpass::handle(me, from, args, ctx, db),
Some("CONFIRM") => confirm::handle(me, from, args, ctx, db),
Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 <password> [email], \x02IDENTIFY\x02 [account] <password>, \x02INFO\x02 [account], \x02ALIST\x02, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 <nick> [password], \x02SET\x02 PASSWORD|EMAIL, \x02RESETPASS\x02 <account>, \x02CONFIRM\x02 <code>, \x02DROP\x02 <password>, \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST <password> [fingerprint]."),
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
None => {}
}
}
}

14
nickserv/src/logout.rs Normal file
View file

@ -0,0 +1,14 @@
use fedserv_api::{Sender, ServiceCtx};
// LOGOUT: log out and rename to a guest nick (prefix + a per-logout sequence).
pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, ctx: &mut ServiceCtx) {
if from.account.is_none() {
ctx.notice(me, from.uid, "You're not logged in.");
return;
}
let guest = format!("{guest_nick}{guest_seq}");
*guest_seq = guest_seq.wrapping_add(1);
ctx.logout(from.uid);
ctx.force_nick(from.uid, &guest);
ctx.notice(me, from.uid, format!("You're now logged out. Your nick is now \x02{}\x02.", guest));
}

17
nickserv/src/register.rs Normal file
View file

@ -0,0 +1,17 @@
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::RegReply;
// REGISTER <password> [email]: register the sender's current nick. The engine
// derives the password off-thread, commits, and answers.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx) {
let Some(password) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: REGISTER <password> [email]");
return;
};
let email = args.get(2).map(|s| s.to_string());
ctx.defer_register(from.nick, *password, email, RegReply::NickServ {
agent: me.to_string(),
uid: from.uid.to_string(),
nick: from.nick.to_string(),
});
}

39
nickserv/src/resetpass.rs Normal file
View file

@ -0,0 +1,39 @@
use fedserv_api::{CodeKind, Store};
use fedserv_api::{Sender, ServiceCtx};
// RESETPASS <account>: email a reset code to the address on file.
// RESETPASS <account> <code> <newpassword>: complete the reset with that code.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !db.email_enabled() {
ctx.notice(me, from.uid, "Password reset by email isn't available here.");
return;
}
match (args.get(1), args.get(2), args.get(3)) {
(Some(&name), None, None) => {
let Some(canonical) = db.resolve_account(name).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
let Some(email) = db.account(&canonical).and_then(|a| a.email.clone()) else {
ctx.notice(me, from.uid, "That account has no email on file, so it can't be reset.");
return;
};
let code = db.issue_code(&canonical, CodeKind::Reset);
let mail = fedserv_api::email::reset(db.email_brand(), db.email_accent(), db.email_logo(), &canonical, &code);
ctx.send_email(email, mail.subject, mail.text, Some(mail.html));
ctx.notice(me, from.uid, format!("A reset code has been emailed to the address on file for \x02{canonical}\x02."));
}
(Some(&name), Some(&code), Some(&newpass)) => {
let Some(canonical) = db.resolve_account(name).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
if !db.take_code(&canonical, CodeKind::Reset, code) {
ctx.notice(me, from.uid, "Invalid or expired reset code.");
return;
}
ctx.defer_password(&canonical, newpass, me, from.uid);
}
_ => ctx.notice(me, from.uid, "Syntax: RESETPASS <account> [<code> <newpassword>]"),
}
}

30
nickserv/src/set.rs Normal file
View file

@ -0,0 +1,30 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// SET PASSWORD <newpassword> | SET EMAIL [address]: change your account settings.
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 be logged in. Identify to NickServ first.");
return;
};
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("PASSWORD") | Some("PASS") => {
let Some(&password) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword>");
return;
};
// The link layer derives the new password off-thread, then commits it.
ctx.defer_password(account, password, me, from.uid);
}
Some("EMAIL") => {
let email = args.get(2).map(|s| s.to_string());
let cleared = email.is_none();
match db.set_email(account, email) {
Ok(()) if cleared => ctx.notice(me, from.uid, format!("Email for \x02{account}\x02 cleared.")),
Ok(()) => ctx.notice(me, from.uid, format!("Email for \x02{account}\x02 updated.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
_ => ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword> | SET EMAIL [address]"),
}
}

24
nickserv/src/ungroup.rs Normal file
View file

@ -0,0 +1,24 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// UNGROUP [nick]: remove a nick grouped to your account (defaults to your current nick).
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 be logged in. Identify to NickServ first.");
return;
};
let nick = args.get(1).copied().unwrap_or(from.nick);
if nick.eq_ignore_ascii_case(account) {
ctx.notice(me, from.uid, "You can't ungroup your main account name.");
return;
}
if db.resolve_account(nick).map_or(true, |a| !a.eq_ignore_ascii_case(account)) {
ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't grouped to your account."));
return;
}
match db.ungroup_nick(nick) {
Ok(true) => ctx.notice(me, from.uid, format!("\x02{nick}\x02 is no longer grouped to \x02{account}\x02.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't a grouped nick.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}