Add opt-in external-account mode (delegate identity to the website)

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.
This commit is contained in:
Jean Chevronnet 2026-07-14 03:12:59 +00:00
parent 4c899d80b0
commit fba91b4d62
No known key found for this signature in database
8 changed files with 108 additions and 3 deletions

View file

@ -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<String>) -> bool {
Db::set_account_note(self, account, note)
}

View file

@ -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<Vec<NetAction>> {
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<NetAct
RegOutcome::Exists => ("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<NetAct
RegOutcome::Exists => vec![notice(format!("\x02{nick}\x02 is already registered. If it's yours, use \x02IDENTIFY <password>\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]