diff --git a/api/src/lib.rs b/api/src/lib.rs index 48b6937..5d3970b 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -2190,6 +2190,11 @@ pub trait Store { fn confusable_check_enabled(&self) -> bool { true } + // Whether registration is invite-only ([register] vouch): a new account waits + // for an existing member to VOUCH for it instead of confirming by email. + fn registration_vouch(&self) -> bool { + false + } // Staff notes on accounts/channels (oper-only), shown in INFO to operators. fn set_account_note(&mut self, account: &str, note: Option) -> bool; fn account_note(&self, account: &str) -> Option; diff --git a/lang/de.json b/lang/de.json index 27b5c24..110a91f 100644 --- a/lang/de.json +++ b/lang/de.json @@ -1369,5 +1369,7 @@ "… and \u0002{more}\u0002 more; showing the first {cap}.": "… und \u0002{more}\u0002 weitere; die ersten {cap} werden angezeigt.", "log in and reclaim your nick": "anmelden und deinen Nick zurückholen", "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Syntax: \u0002LOGIN \u0002\nMeldet dich beim Konto von an und setzt dich auf diesen Nick, wobei jede Sitzung, die ihn belegt, freigegeben wird.", - "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nÜberträgt den Gründer oder ändert eine Kanaleinstellung." + "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nÜberträgt den Gründer oder ändert eine Kanaleinstellung.", + "Your account is pending. Ask an existing member to vouch for you with \u0002/msg NickServ VOUCH {account}\u0002.": "Dein Konto ist ausstehend. Bitte ein bestehendes Mitglied, mit \u0002/msg NickServ VOUCH {account}\u0002 für dich zu bürgen.", + "You vouched for \u0002{account}\u0002; their account is now active.": "Du hast für \u0002{account}\u0002 gebürgt; das Konto ist jetzt aktiv." } \ No newline at end of file diff --git a/lang/es-ar.json b/lang/es-ar.json index 76d73c1..3940d02 100644 --- a/lang/es-ar.json +++ b/lang/es-ar.json @@ -1369,5 +1369,7 @@ "… and \u0002{more}\u0002 more; showing the first {cap}.": "… y \u0002{more}\u0002 más; mostrando los primeros {cap}.", "log in and reclaim your nick": "iniciar sesión y recuperar tu apodo", "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxis: \u0002LOGIN \u0002\nInicia sesión en la cuenta de y te mueve a ese apodo, liberando cualquier sesión que ya lo esté usando.", - "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxis: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfiere el fundador o cambia un ajuste del canal." + "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxis: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfiere el fundador o cambia un ajuste del canal.", + "Your account is pending. Ask an existing member to vouch for you with \u0002/msg NickServ VOUCH {account}\u0002.": "Tu cuenta está pendiente. Pedile a un miembro existente que te avale con \u0002/msg NickServ VOUCH {account}\u0002.", + "You vouched for \u0002{account}\u0002; their account is now active.": "Avalaste a \u0002{account}\u0002; su cuenta ya está activa." } \ No newline at end of file diff --git a/lang/es.json b/lang/es.json index cdd2f1d..4246b5b 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1369,5 +1369,7 @@ "… and \u0002{more}\u0002 more; showing the first {cap}.": "… y \u0002{more}\u0002 más; mostrando los primeros {cap}.", "log in and reclaim your nick": "iniciar sesión y recuperar tu apodo", "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxis: \u0002LOGIN \u0002\nInicia sesión en la cuenta de y te mueve a ese apodo, liberando cualquier sesión que ya lo ocupe.", - "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxis: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfiere el fundador o cambia un ajuste del canal." + "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxis: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfiere el fundador o cambia un ajuste del canal.", + "Your account is pending. Ask an existing member to vouch for you with \u0002/msg NickServ VOUCH {account}\u0002.": "Tu cuenta está pendiente. Pide a un miembro existente que te avale con \u0002/msg NickServ VOUCH {account}\u0002.", + "You vouched for \u0002{account}\u0002; their account is now active.": "Avalaste a \u0002{account}\u0002; su cuenta ya está activa." } \ No newline at end of file diff --git a/lang/fr.json b/lang/fr.json index 81a033a..26ab5d5 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -1369,5 +1369,7 @@ "… and \u0002{more}\u0002 more; showing the first {cap}.": "… et \u0002{more}\u0002 de plus ; affichage des {cap} premiers.", "log in and reclaim your nick": "se connecter et récupérer son pseudo", "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Syntaxe : \u0002LOGIN \u0002\nVous connecte au compte de et vous place sur ce pseudo, en libérant toute session qui l'occupe déjà.", - "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Syntaxe : \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfère le fondateur ou modifie un réglage du salon." + "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Syntaxe : \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfère le fondateur ou modifie un réglage du salon.", + "Your account is pending. Ask an existing member to vouch for you with \u0002/msg NickServ VOUCH {account}\u0002.": "Votre compte est en attente. Demandez à un membre existant de vous parrainer avec \u0002/msg NickServ VOUCH {account}\u0002.", + "You vouched for \u0002{account}\u0002; their account is now active.": "Vous avez parrainé \u0002{account}\u0002 ; son compte est maintenant actif." } \ No newline at end of file diff --git a/lang/pt-br.json b/lang/pt-br.json index 17106f2..ca5c60b 100644 --- a/lang/pt-br.json +++ b/lang/pt-br.json @@ -1369,5 +1369,7 @@ "… and \u0002{more}\u0002 more; showing the first {cap}.": "… e mais \u0002{more}\u0002; mostrando os primeiros {cap}.", "log in and reclaim your nick": "entrar e recuperar seu apelido", "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxe: \u0002LOGIN \u0002\nEntra na conta de e move você para esse apelido, liberando qualquer sessão que já o ocupe.", - "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxe: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfere o fundador ou altera uma configuração do canal." + "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxe: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfere o fundador ou altera uma configuração do canal.", + "Your account is pending. Ask an existing member to vouch for you with \u0002/msg NickServ VOUCH {account}\u0002.": "Sua conta está pendente. Peça a um membro existente para avalizar você com \u0002/msg NickServ VOUCH {account}\u0002.", + "You vouched for \u0002{account}\u0002; their account is now active.": "Você avalizou \u0002{account}\u0002; a conta agora está ativa." } \ No newline at end of file diff --git a/lang/pt.json b/lang/pt.json index a96d40c..7c2ed5b 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -1369,5 +1369,7 @@ "… and \u0002{more}\u0002 more; showing the first {cap}.": "… e mais \u0002{more}\u0002; a mostrar os primeiros {cap}.", "log in and reclaim your nick": "iniciar sessão e recuperar a sua alcunha", "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxe: \u0002LOGIN \u0002\nInicia sessão na conta de e move-o para essa alcunha, libertando qualquer sessão que já a ocupe.", - "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxe: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfere o fundador ou altera uma definição do canal." + "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxe: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfere o fundador ou altera uma definição do canal.", + "Your account is pending. Ask an existing member to vouch for you with \u0002/msg NickServ VOUCH {account}\u0002.": "A sua conta está pendente. Peça a um membro existente que o avalize com \u0002/msg NickServ VOUCH {account}\u0002.", + "You vouched for \u0002{account}\u0002; their account is now active.": "Avalizou \u0002{account}\u0002; a conta está agora ativa." } \ No newline at end of file diff --git a/modules/nickserv/src/lib.rs b/modules/nickserv/src/lib.rs index 0a69202..3898f04 100644 --- a/modules/nickserv/src/lib.rs +++ b/modules/nickserv/src/lib.rs @@ -7,6 +7,7 @@ mod register; #[path = "identify.rs"] mod identify; mod login; +mod vouch; #[path = "logout.rs"] mod logout; #[path = "cert.rs"] @@ -53,6 +54,7 @@ const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "REGISTER", summary: "register your nick as an account", detail: "Syntax: \x02REGISTER [email]\x02\nRegisters your current nick as an account. If an email is given and confirmation is on, you get a code to \x02CONFIRM\x02." }, HelpEntry { cmd: "IDENTIFY", summary: "log in to your account", detail: "Syntax: \x02IDENTIFY [account] \x02\nLogs you in. Also \x02ID\x02." }, HelpEntry { cmd: "LOGIN", summary: "log in and reclaim your nick", detail: "Syntax: \x02LOGIN \x02\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it." }, + HelpEntry { cmd: "VOUCH", summary: "confirm a pending member", detail: "Syntax: \x02VOUCH \x02\nOn an invite-only network, confirms a newly-registered account so it becomes active. Any identified member may vouch." }, HelpEntry { cmd: "LOGOUT", summary: "log out to a guest nick", detail: "Syntax: \x02LOGOUT\x02\nLogs you out and moves you to a guest nick. Also \x02LOGOFF\x02." }, HelpEntry { cmd: "INFO", summary: "show account information", detail: "Syntax: \x02INFO [account]\x02\nShows account information. The email is shown only to the owner." }, HelpEntry { cmd: "ALIST", summary: "list channels you have access on", detail: "Syntax: \x02ALIST\x02\nLists the channels you hold access on." }, @@ -129,6 +131,7 @@ impl Service for NickServ { Some("RECOVER") => ghost::handle(me, &self.guest_nick, &mut self.guest_seq, from, args, ctx, net, db, true), Some("RESETPASS") => resetpass::handle(me, from, args, ctx, db), Some("CONFIRM") => confirm::handle(me, from, args, ctx, db), + Some("VOUCH") => vouch::handle(me, from, args, ctx, db), Some("AJOIN") => ajoin::handle(me, from, args, ctx, net, db), Some("SUSPEND") => suspend::handle(me, from, args, ctx, net, db, true), Some("UNSUSPEND") => suspend::handle(me, from, args, ctx, net, db, false), diff --git a/modules/nickserv/src/vouch.rs b/modules/nickserv/src/vouch.rs new file mode 100644 index 0000000..ab3ad63 --- /dev/null +++ b/modules/nickserv/src/vouch.rs @@ -0,0 +1,45 @@ +use echo_api::{Sender, ServiceCtx, Store}; +use echo_api::t; + +// VOUCH : confirm a pending (invite-only) account so it becomes active. Any +// identified member may vouch; the action is announced to staff so a bad account +// can be traced back to whoever vouched for it. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !db.registration_vouch() { + ctx.notice(me, from.uid, "Vouching isn't enabled on this network."); + return; + } + let Some(account) = from.account else { + ctx.notice(me, from.uid, "You must be identified to vouch for someone."); + return; + }; + // Only an already-confirmed member may vouch, or the invite gate is trivially + // bypassed by registering a second account and self-vouching. + if !db.is_verified(account) { + ctx.notice(me, from.uid, "Only confirmed members can vouch for others."); + return; + } + let Some(&target) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: VOUCH "); + return; + }; + let Some(tacct) = db.resolve_account(target).map(str::to_string) else { + ctx.notice(me, from.uid, t!(ctx, "\x02{target}\x02 isn't registered.", target = target)); + return; + }; + if tacct.eq_ignore_ascii_case(account) { + ctx.notice(me, from.uid, "You can't vouch for yourself."); + return; + } + if db.is_verified(&tacct) { + ctx.notice(me, from.uid, t!(ctx, "\x02{account}\x02 is already confirmed.", account = tacct)); + return; + } + match db.verify_account(&tacct) { + Ok(()) => { + ctx.notice(me, from.uid, t!(ctx, "You vouched for \x02{account}\x02; their account is now active.", account = tacct)); + ctx.alert("REGISTER", format!("vouched for \x02{tacct}\x02")); + } + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} diff --git a/src/config.rs b/src/config.rs index dab4e18..a612a23 100644 --- a/src/config.rs +++ b/src/config.rs @@ -126,11 +126,16 @@ pub struct Register { // default; turn it off for a community that legitimately uses mixed or // non-Latin names. Reloadable with REHASH. pub confusable_check: bool, + // Invite-only registration: a new account stays pending until an existing + // member vouches for it (NickServ VOUCH), instead of confirming by email. + // Off by default. Reloadable with REHASH. + #[serde(default)] + pub vouch: bool, } impl Default for Register { fn default() -> Self { - Self { confusable_check: true } + Self { confusable_check: true, vouch: false } } } diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index a4cb2d1..5324a5f 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -6,9 +6,10 @@ impl Db { if self.exists(name) { return Err(RegError::Exists); } - // Unverified only when email confirmation actually applies (email is - // configured and an address was given); otherwise verified immediately. - let verified = !(self.email_enabled && email.is_some()); + // Unverified when email confirmation applies (email configured and an + // address given) OR the network is invite-only (a member must VOUCH); + // otherwise verified immediately. + let verified = !(self.registration_vouch || (self.email_enabled && email.is_some())); let ts = now(); // one timestamp: registered-at == last-seen at creation let account = Account { name: name.to_string(), diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index c5794b1..ab5d000 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -1181,6 +1181,7 @@ pub struct Db { // against accounts pushed in (via the gRPC Accounts API). Node-local config. external_accounts: bool, confusable_check: bool, // reject look-alike / mixed-script REGISTER names + registration_vouch: bool, // invite-only: new accounts wait for a member to vouch default_language: String, // network-wide reply language for users with no preference available_languages: Vec, // language codes a user may pick with SET LANGUAGE } @@ -1251,7 +1252,7 @@ impl Db { apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event); } tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); - Self { accounts, channels, grouped, log, extban_enabled: None, notify_exclude: Vec::new(), live_extbans: None, live_chanmodes: None, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), email_confirm_url: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false, confusable_check: true, default_language: "en".to_string(), available_languages: vec!["en".to_string()] } + Self { accounts, channels, grouped, log, extban_enabled: None, notify_exclude: Vec::new(), live_extbans: None, live_chanmodes: None, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), email_confirm_url: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false, confusable_check: true, registration_vouch: false, default_language: "en".to_string(), available_languages: vec!["en".to_string()] } } /// Fold an entry authored by another node into the store — the services-side diff --git a/src/engine/db/network.rs b/src/engine/db/network.rs index 6682309..923745e 100644 --- a/src/engine/db/network.rs +++ b/src/engine/db/network.rs @@ -355,6 +355,10 @@ impl Db { self.confusable_check = on; } + pub fn set_registration_vouch(&mut self, on: bool) { + self.registration_vouch = on; + } + /// The network-wide reply language for users who haven't set a preference. pub fn set_default_language(&mut self, lang: &str) { if !lang.is_empty() { @@ -382,6 +386,10 @@ impl Db { self.confusable_check } + pub fn registration_vouch(&self) -> bool { + self.registration_vouch + } + /// The network defence level (5 = normal, 1 = full lockdown). pub fn defcon(&self) -> u8 { self.defcon diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index 36e9a9d..833faa0 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -338,6 +338,9 @@ impl Store for Db { fn confusable_check_enabled(&self) -> bool { self.confusable_check } + fn registration_vouch(&self) -> bool { + Db::registration_vouch(self) + } fn set_account_note(&mut self, account: &str, note: Option) -> bool { Db::set_account_note(self, account, note) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 4418e31..72d85fc 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1002,6 +1002,7 @@ impl Engine { self.set_log_channel(cfg.log.as_ref().map(|l| l.channel.clone())); self.db.set_notify_exclude(cfg.log.as_ref().map(|l| l.notify_exclude.clone()).unwrap_or_default()); self.db.set_confusable_check(cfg.register.confusable_check); + self.db.set_registration_vouch(cfg.register.vouch); self.set_guest_nick(&cfg.server.guest_nick); if let Some(expire) = &cfg.expire { self.set_expiry(expire.account_ttl(), expire.channel_ttl(), expire.warn_ttl()); diff --git a/src/engine/register.rs b/src/engine/register.rs index 60f38b1..e0c2cc3 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -342,7 +342,12 @@ impl Engine { // NickServ or the account-registration relay path. The relay reply // already carries verification_required; NickServ users get a notice. if needs_verify { - if let Some(addr) = addr { + if self.db.registration_vouch() { + // Invite-only: the account waits for a member to VOUCH, not an email code. + if let RegReply::NickServ { agent, uid, .. } = &reply { + out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: echo_api::render(&self.lang_for_account(account), "Your account is pending. Ask an existing member to vouch for you with \x02/msg NickServ VOUCH {account}\x02.", &[("account", account.to_string())]) }); + } + } else if let Some(addr) = addr { let code = self.db.issue_code(account, db::CodeKind::Confirm); let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), account, &code, self.db.email_confirm_url(), &self.lang_for_account(account)); out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) }); diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 4f79cc0..83ee54b 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -5344,6 +5344,27 @@ assert!(!out.iter().any(|a| matches!(a, NetAction::Kick { reason, .. } if reason.contains("requested by"))), "op founder exempt on LEVEL: {out:?}"); } + // Invite-only registration: a new account starts pending, and a confirmed + // member's VOUCH activates it. + #[test] + fn vouch_confirms_a_pending_account() { + let path = std::env::temp_dir().join("echo-vouch.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("alice", "sesame", None).unwrap(); // verified (vouch off) + db.set_registration_vouch(true); + db.register("bob", "pw", None).unwrap(); // invite-only -> pending + assert!(!db.is_verified("bob"), "bob starts pending under vouch mode"); + let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; + let mut e = Engine::new(vec![Box::new(ns)], db); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "VOUCH bob".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("vouched"))), "vouch confirmation: {out:?}"); + assert!(e.db.is_verified("bob"), "bob is now active after the vouch"); + } + // ChanServ SET: description and founder transfer, founder-gated. #[test] fn chanserv_set() { diff --git a/src/main.rs b/src/main.rs index 0f52c17..9a6d2fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -241,6 +241,7 @@ async fn main() -> Result<()> { db.set_email_enabled(cfg.email.is_some()); db.set_external_accounts(cfg.auth.as_ref().is_some_and(|a| a.external)); db.set_confusable_check(cfg.register.confusable_check); + db.set_registration_vouch(cfg.register.vouch); if let Some(lang) = &cfg.language { db.set_default_language(&lang.default); db.set_available_languages(lang.available.clone());