From fba91b4d62986d1cd70faf937673e9203147d7e6 Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 14 Jul 2026 03:12:59 +0000 Subject: [PATCH] Add opt-in external-account mode (delegate identity to the website) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By default fedserv owns accounts itself — nothing changes, no external service required. Set [auth] external = true and an outside authority (the website) owns identity instead: NickServ REGISTER / DROP / SET PASSWORD|EMAIL / RESETPASS / CONFIRM / CERT / GROUP and the IRCv3 registration relay are all refused ("managed on the website"), so IRC can't mint or change a second identity. Login is unchanged — fedserv still authenticates locally against the accounts the authority pushes in via the existing gRPC Accounts API, so lookups stay in-memory fast. fedserv keeps owning all IRC-domain data (channels, access, vhosts, bans, memos) keyed by the account name in both modes. One config bool, off by default, so standalone deployments are unaffected. --- api/src/lib.rs | 3 +++ config.example.toml | 10 +++++++++ nickserv/src/lib.rs | 9 ++++++++- nickserv/src/set.rs | 8 +++++++- src/config.rs | 12 +++++++++++ src/engine/db.rs | 19 +++++++++++++++++- src/engine/mod.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 8 files changed, 108 insertions(+), 3 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 168a637..60700c8 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -776,6 +776,9 @@ pub trait Store { fn set_defcon(&mut self, level: u8); fn registrations_frozen(&self) -> bool; fn channel_regs_frozen(&self) -> bool; + // Whether account identity is owned externally (website); when true, IRC + // can't register or change credentials — it only authenticates. + fn external_accounts(&self) -> bool; // 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/config.example.toml b/config.example.toml index e716374..da056f5 100644 --- a/config.example.toml +++ b/config.example.toml @@ -74,3 +74,13 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205 # per IP-mask (an exception limit of 0 means unlimited). 0 or omitted = off. # [session] # default_limit = 3 + +# Account authority. Omit this section (the default) and fedserv owns accounts +# itself: NickServ REGISTER / IDENTIFY / SET PASSWORD all work standalone, no +# external service needed. Set external = true to hand identity to an outside +# authority (e.g. your website): IRC can then only IDENTIFY — REGISTER, DROP, +# SET PASSWORD/EMAIL, RESETPASS, CONFIRM, CERT and GROUP are refused, and the +# authority pushes accounts in via the gRPC Accounts API (see [grpc]). fedserv +# still owns all channel/vhost/ban data, keyed by the account name. +# [auth] +# external = true diff --git a/nickserv/src/lib.rs b/nickserv/src/lib.rs index 48b5664..2dbb404 100644 --- a/nickserv/src/lib.rs +++ b/nickserv/src/lib.rs @@ -62,7 +62,14 @@ impl Service for NickServ { 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() { + let cmd = args.first().map(|s| s.to_ascii_uppercase()); + // When identity is owned externally (the website), IRC can log in but not + // create or change an account — those commands are refused. + if db.external_accounts() && matches!(cmd.as_deref(), Some("REGISTER" | "DROP" | "RESETPASS" | "CONFIRM" | "CERT" | "GROUP" | "UNGROUP")) { + ctx.notice(me, from.uid, "Your account is managed on the website — register or change it there. From IRC you can only \x02IDENTIFY\x02."); + return; + } + match cmd.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), diff --git a/nickserv/src/set.rs b/nickserv/src/set.rs index b065fe9..bb32255 100644 --- a/nickserv/src/set.rs +++ b/nickserv/src/set.rs @@ -7,7 +7,13 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: 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() { + let sub = args.get(1).map(|s| s.to_ascii_uppercase()); + // Credential/identity fields are owned by the website in external mode. + if db.external_accounts() && matches!(sub.as_deref(), Some("PASSWORD" | "PASS" | "EMAIL")) { + ctx.notice(me, from.uid, "That's managed on the website — change it there."); + return; + } + match sub.as_deref() { Some("PASSWORD") | Some("PASS") => { let Some(&password) = args.get(2) else { ctx.notice(me, from.uid, "Syntax: SET PASSWORD "); diff --git a/src/config.rs b/src/config.rs index a553762..987bb07 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,6 +35,18 @@ pub struct Config { // Per-IP session limiting. Absent = unlimited. #[serde(default)] pub session: Option, + // Account authority. Absent = built-in (fedserv owns accounts). With + // `external = true`, an outside authority (e.g. the website) owns identity + // and pushes accounts in; IRC can only authenticate. + #[serde(default)] + pub auth: Option, +} + +// Account-authority configuration. +#[derive(Debug, Deserialize, Clone)] +pub struct Auth { + #[serde(default)] + pub external: bool, } // Session limiting: the default connections allowed per IP (0/absent = off), diff --git a/src/engine/db.rs b/src/engine/db.rs index 32ecf5f..d37927a 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -985,6 +985,10 @@ pub struct Db { // Network defence level (OperServ DEFCON), 5 = normal down to 1 = lockdown. // Node-local; the derived restrictions are read by the engine and services. defcon: u8, + // When set, account identity is owned by an external authority (e.g. the + // website): IRC can't register or change credentials, only authenticate + // against accounts pushed in (via the gRPC Accounts API). Node-local config. + external_accounts: bool, } // A juped server: the held name, the fake server id we allocated for it, and why. @@ -1048,7 +1052,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, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), jupes: Vec::new(), jupe_seq: 0, defcon: 5 } + Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), jupes: Vec::new(), jupe_seq: 0, defcon: 5, external_accounts: false } } /// Fold an entry authored by another node into the store — the services-side @@ -2053,6 +2057,16 @@ impl Db { .collect() } + /// Whether account identity is owned by an external authority. + pub fn external_accounts(&self) -> bool { + self.external_accounts + } + + /// Set external-account mode (from config, at startup). + pub fn set_external_accounts(&mut self, on: bool) { + self.external_accounts = on; + } + /// The network defence level (5 = normal, 1 = full lockdown). pub fn defcon(&self) -> u8 { self.defcon @@ -3359,6 +3373,9 @@ impl Store for Db { fn channel_regs_frozen(&self) -> bool { Db::channel_regs_frozen(self) } + fn external_accounts(&self) -> bool { + Db::external_accounts(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 ed4d7c2..34c407b 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1147,6 +1147,9 @@ impl Engine { // outright, and rate-limit the rest so a REGISTER flood can't pin CPU. Returns // the rejection response if refused, or None to proceed (spending a token). pub fn pre_register_check(&mut self, account: &str, reply: &RegReply) -> Option> { + if self.db.external_accounts() { + return Some(reg_reply(reply, RegOutcome::External, account)); + } if self.db.registrations_frozen() { return Some(reg_reply(reply, RegOutcome::Frozen, account)); } @@ -1779,6 +1782,7 @@ enum RegOutcome { Exists, RateLimited, Frozen, + External, Internal, } @@ -1792,6 +1796,7 @@ fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec ("error", "ACCOUNT_EXISTS", "That account name is already registered."), RegOutcome::RateLimited => ("error", "TEMPORARILY_UNAVAILABLE", "Too many registrations, please wait a moment."), RegOutcome::Frozen => ("error", "TEMPORARILY_UNAVAILABLE", "Registrations are temporarily frozen by network staff."), + RegOutcome::External => ("error", "ACCOUNT_REGISTRATION_DISABLED", "Accounts are managed on the website; register there."), RegOutcome::Internal => ("error", "TEMPORARILY_UNAVAILABLE", "Registration is unavailable, try again later."), }; vec![NetAction::AccountResponse { @@ -1814,6 +1819,7 @@ fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec vec![notice(format!("\x02{nick}\x02 is already registered. If it's yours, use \x02IDENTIFY \x02."))], RegOutcome::RateLimited => vec![notice("Registrations are busy right now. Please try again in a moment.".to_string())], RegOutcome::Frozen => vec![notice("Registrations are temporarily frozen by network staff. Please try again later.".to_string())], + RegOutcome::External => vec![notice("Accounts are managed on the website — register there.".to_string())], RegOutcome::Internal => vec![notice("Sorry, that didn't work. Please try again in a moment.".to_string())], } } @@ -4313,6 +4319,49 @@ mod tests { assert!(!e.startup_actions().iter().any(|a| matches!(a, NetAction::JupeServer { .. })), "gone after DEL"); } + // External-account mode: IRC can log in against pushed-in accounts but can't + // register or change identity; channels (fedserv's own domain) still work. + #[test] + fn external_accounts_delegate_identity() { + use fedserv_chanserv::ChanServ; + let path = std::env::temp_dir().join("fedserv-external.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + // Simulates an account pushed in by the external authority (the website). + db.register("alice", "password1", None).unwrap(); + db.set_external_accounts(true); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(ChanServ { uid: "42SAAAAAB".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() }); + let cs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAB".into(), text: t.into() }); + let has = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n))); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); + + // Login against the pushed-in account still works. + let out = ns(&mut e, "000AAAAAB", "IDENTIFY password1"); + assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "alice")), "identify works: {out:?}"); + + // But creating or changing identity from IRC is refused. + assert!(has(&ns(&mut e, "000AAAAAB", "REGISTER newpass a@b.c"), "website"), "register refused"); + assert!(has(&ns(&mut e, "000AAAAAB", "SET PASSWORD hunter2"), "website"), "set password refused"); + assert!(has(&ns(&mut e, "000AAAAAB", "DROP password1"), "website"), "drop refused"); + // The IRCv3 registration relay is refused too. + let reply = crate::proto::RegReply::NickServ { agent: "42SAAAAAA".into(), uid: "000AAAAAB".into(), nick: "bob".into() }; + assert!(e.pre_register_check("bob", &reply).is_some_and(|r| r.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("website")))), "relay refused"); + + // Channels are fedserv's own domain — still registerable. + e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: true }); + assert!(has(&cs(&mut e, "000AAAAAB", "REGISTER #room"), "now registered"), "channels still work"); + } + // OperServ DEFCON: each level tightens the network — announce on change, freeze // channel then all registrations, cap sessions, and lock out new connections. #[test] diff --git a/src/main.rs b/src/main.rs index 152f3ed..1b67d70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,6 +99,7 @@ async fn main() -> Result<()> { db.scram_iterations = cfg.server.scram_iterations; db.set_outbound(gossip_tx.clone()); db.set_email_enabled(cfg.email.is_some()); + db.set_external_accounts(cfg.auth.as_ref().is_some_and(|a| a.external)); if let Some(email) = &cfg.email { db.set_email_brand(&email.brand); db.set_email_accent(&email.accent);