diff --git a/.gitignore b/.gitignore index 4a3b37d..7f7062f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target config.toml + +fedserv.db.jsonl diff --git a/Cargo.lock b/Cargo.lock index 6f26be8..7781370 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,42 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bytes" version = "1.12.1" @@ -29,6 +65,36 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -40,13 +106,36 @@ name = "fedserv" version = "0.0.1" dependencies = [ "anyhow", + "argon2", "serde", + "serde_json", "tokio", "toml", "tracing", "tracing-subscriber", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -63,6 +152,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "lazy_static" version = "1.5.0" @@ -122,6 +217,17 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -146,6 +252,15 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "regex-automata" version = "0.4.15" @@ -193,6 +308,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -227,6 +355,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.118" @@ -375,6 +509,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -387,6 +527,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -416,3 +562,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 42408d3..a7dd2b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,9 @@ description = "Federated IRC services daemon (protocol-agnostic core, InspIRCd l [dependencies] tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time"] } serde = { version = "1", features = ["derive"] } +serde_json = "1" toml = "0.8" +argon2 = { version = "0.5", features = ["std"] } anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/engine/db.rs b/src/engine/db.rs new file mode 100644 index 0000000..438035a --- /dev/null +++ b/src/engine/db.rs @@ -0,0 +1,101 @@ +use std::collections::HashMap; +use std::io::Write; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::SaltString; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Account { + pub name: String, + pub password_hash: String, + pub email: Option, + pub ts: u64, +} + +// Event-sourced persistence: every change is an Event appended to a JSONL log, +// and account state is the fold of that log. Replicating this log across nodes +// is what turns the store federated later, without changing the services. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event")] +pub enum Event { + AccountRegistered(Account), +} + +pub enum RegError { + Exists, + Internal, +} + +pub struct Db { + accounts: HashMap, // keyed by casefolded name + path: PathBuf, +} + +fn key(name: &str) -> String { + name.to_ascii_lowercase() +} + +fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +impl Db { + pub fn open(path: impl Into) -> Self { + let path = path.into(); + let mut accounts = HashMap::new(); + if let Ok(data) = std::fs::read_to_string(&path) { + for line in data.lines().filter(|l| !l.trim().is_empty()) { + match serde_json::from_str::(line) { + Ok(Event::AccountRegistered(a)) => { + accounts.insert(key(&a.name), a); + } + Err(e) => tracing::warn!(%e, "skipping malformed event log line"), + } + } + } + tracing::info!(accounts = accounts.len(), ?path, "account store loaded"); + Self { accounts, path } + } + + pub fn exists(&self, name: &str) -> bool { + self.accounts.contains_key(&key(name)) + } + + pub fn register(&mut self, name: &str, password: &str, email: Option) -> Result<(), RegError> { + if self.exists(name) { + return Err(RegError::Exists); + } + let password_hash = hash_password(password).ok_or(RegError::Internal)?; + let account = Account { name: name.to_string(), password_hash, email, ts: now() }; + self.append(&Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?; + self.accounts.insert(key(name), account); + Ok(()) + } + + pub fn verify(&self, name: &str, password: &str) -> bool { + match self.accounts.get(&key(name)) { + Some(a) => verify_password(password, &a.password_hash), + None => false, + } + } + + fn append(&self, event: &Event) -> std::io::Result<()> { + let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?; + writeln!(f, "{}", serde_json::to_string(event).unwrap_or_default()) + } +} + +fn hash_password(password: &str) -> Option { + let salt = SaltString::generate(&mut OsRng); + Argon2::default().hash_password(password.as_bytes(), &salt).ok().map(|h| h.to_string()) +} + +fn verify_password(password: &str, hash: &str) -> bool { + PasswordHash::new(hash) + .map(|parsed| Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()) + .unwrap_or(false) +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 256e0a7..1fc2777 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1,25 +1,21 @@ +pub mod db; pub mod service; pub mod state; use crate::proto::{NetAction, NetEvent}; -use service::{Service, ServiceCtx}; -use state::{EventLog, Network}; +use db::Db; +use service::{Sender, Service, ServiceCtx}; +use state::Network; pub struct Engine { services: Vec>, - #[allow(dead_code)] network: Network, - #[allow(dead_code)] - log: EventLog, + db: Db, } impl Engine { - pub fn new(services: Vec>) -> Self { - Self { - services, - network: Network::default(), - log: EventLog::default(), - } + pub fn new(services: Vec>, db: Db) -> Self { + Self { services, network: Network::default(), db } } // Sent right after the SERVER line: burst, introduce every service, endburst. @@ -41,18 +37,30 @@ impl Engine { pub fn handle(&mut self, event: NetEvent) -> Vec { match event { NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }], + NetEvent::UserConnect { uid, nick } => { + self.network.user_connect(uid, nick); + Vec::new() + } + NetEvent::Quit { uid } => { + self.network.user_quit(&uid); + Vec::new() + } NetEvent::Privmsg { from, to, text } => self.dispatch(&from, &to, &text), _ => Vec::new(), } } - // Route a PRIVMSG addressed to a service (by uid or nick) into that service. + // Route a PRIVMSG addressed to a service (by uid or nick) into that service, + // handing it the sender's resolved nick and the account store. fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec { + let nick = self.network.nick_of(from).unwrap_or(from).to_string(); let mut ctx = ServiceCtx::default(); - for svc in self.services.iter_mut() { + let sender = Sender { uid: from, nick: &nick }; + let Self { services, db, .. } = self; + for svc in services.iter_mut() { if to.eq_ignore_ascii_case(svc.uid()) || to.eq_ignore_ascii_case(svc.nick()) { let args: Vec<&str> = text.split_whitespace().collect(); - svc.on_command(from, &args, &mut ctx); + svc.on_command(&sender, &args, &mut ctx, db); break; } } diff --git a/src/engine/service.rs b/src/engine/service.rs index d2ed7ba..f8262b5 100644 --- a/src/engine/service.rs +++ b/src/engine/service.rs @@ -1,7 +1,14 @@ +use crate::engine::db::Db; use crate::proto::NetAction; -// A pseudo-client (NickServ, ChanServ, OperServ, ...). Introduced at burst, -// receives the commands users message it, and pushes actions onto the ctx. +// Who sent the command, resolved by the engine (UID + current nick). +pub struct Sender<'a> { + pub uid: &'a str, + pub nick: &'a str, +} + +// A pseudo-client (NickServ, ChanServ, ...). Introduced at burst, receives the +// commands users message it, reads/writes the account store, and pushes actions. pub trait Service: Send { fn nick(&self) -> &str; fn uid(&self) -> &str; @@ -9,10 +16,9 @@ pub trait Service: Send { "services.local" } fn gecos(&self) -> &str; - fn on_command(&mut self, from: &str, args: &[&str], ctx: &mut ServiceCtx); + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db); } -// What a service can do in response to a command, collected for the link to flush. #[derive(Default)] pub struct ServiceCtx { pub actions: Vec, diff --git a/src/engine/state.rs b/src/engine/state.rs index 0bfc20a..7dfe8c0 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -1,39 +1,34 @@ use std::collections::HashMap; -// Live network view the services reason about. +// Live network view, rebuilt from the uplink's burst each connect (ephemeral — +// unlike the account store, which persists). #[derive(Default)] pub struct Network { - pub users: HashMap, // keyed by UID + pub users: HashMap, // keyed by UID pub channels: HashMap, } pub struct User { pub uid: String, pub nick: String, - pub account: Option, } +#[allow(dead_code)] pub struct Channel { pub name: String, pub ts: u64, } -// The Sable-inspired core: every persistent change is an Event, and state is a -// fold over the log. Single-node today; replicating this log across service -// nodes is what turns it federated later, without rewriting the services. -#[derive(Debug, Clone)] -pub enum Event { - AccountRegistered { account: String, uid: String }, - ChannelRegistered { channel: String, founder: String }, -} +impl Network { + pub fn user_connect(&mut self, uid: String, nick: String) { + self.users.insert(uid.clone(), User { uid, nick }); + } -#[derive(Default)] -pub struct EventLog { - pub events: Vec, -} + pub fn user_quit(&mut self, uid: &str) { + self.users.remove(uid); + } -impl EventLog { - pub fn append(&mut self, event: Event) { - self.events.push(event); + pub fn nick_of(&self, uid: &str) -> Option<&str> { + self.users.get(uid).map(|u| u.nick.as_str()) } } diff --git a/src/main.rs b/src/main.rs index b12e68d..24d09d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,7 +35,8 @@ async fn main() -> Result<()> { let services: Vec> = vec![Box::new(NickServ { uid: format!("{}AAAAAA", cfg.server.sid), })]; - let engine = Engine::new(services); + let db = engine::db::Db::open("fedserv.db.jsonl"); + let engine = Engine::new(services, db); let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port); tracing::info!(server = %cfg.server.name, %addr, "linking to uplink"); diff --git a/src/proto/inspircd.rs b/src/proto/inspircd.rs index 279e202..f2f4f04 100644 --- a/src/proto/inspircd.rs +++ b/src/proto/inspircd.rs @@ -69,6 +69,17 @@ impl Protocol for InspIrcd { text: trailing(rest), }] } + // UID … — track who's online so services can + // resolve a sender's current nick. + "UID" => { + let a: Vec<&str> = tokens.collect(); + match (a.first(), a.get(2)) { + (Some(uid), Some(nick)) => { + vec![NetEvent::UserConnect { uid: uid.to_string(), nick: nick.to_string() }] + } + _ => vec![], + } + } "QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }], _ => vec![NetEvent::Unknown { line: line.to_string() }], } diff --git a/src/proto/mod.rs b/src/proto/mod.rs index e9cd068..96ac572 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -10,6 +10,7 @@ pub enum NetEvent { EndBurst, Ping { token: String, from: Option }, Privmsg { from: String, to: String, text: String }, + UserConnect { uid: String, nick: String }, Quit { uid: String }, Unknown { line: String }, } diff --git a/src/services/nickserv.rs b/src/services/nickserv.rs index 2ce42f0..9dfe03e 100644 --- a/src/services/nickserv.rs +++ b/src/services/nickserv.rs @@ -1,4 +1,5 @@ -use crate::engine::service::{Service, ServiceCtx}; +use crate::engine::db::{Db, RegError}; +use crate::engine::service::{Sender, Service, ServiceCtx}; pub struct NickServ { pub uid: String, @@ -15,10 +16,37 @@ impl Service for NickServ { "Nickname Services" } - fn on_command(&mut self, from: &str, args: &[&str], ctx: &mut ServiceCtx) { + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { - Some("HELP") => ctx.notice(self.uid(), from, "Commands: REGISTER, IDENTIFY (coming soon)."), - Some(other) => ctx.notice(self.uid(), from, format!("Unknown command: {}. Try HELP.", other)), + Some("REGISTER") => { + // REGISTER [email] — registers the sender's current nick. + let Some(password) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: REGISTER [email]"); + return; + }; + let email = args.get(2).map(|s| s.to_string()); + match db.register(from.nick, password, email) { + Ok(()) => ctx.notice(me, from.uid, format!("Nickname \x02{}\x02 is now registered.", from.nick)), + Err(RegError::Exists) => ctx.notice(me, from.uid, format!("Nickname \x02{}\x02 is already registered.", from.nick)), + Err(RegError::Internal) => ctx.notice(me, from.uid, "Registration failed, please try again later."), + } + } + Some("IDENTIFY") => { + let Some(password) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: IDENTIFY "); + return; + }; + if db.verify(from.nick, password) { + ctx.notice(me, from.uid, format!("You are now identified for \x02{}\x02.", from.nick)); + } else { + ctx.notice(me, from.uid, "Invalid password."); + } + } + Some("HELP") => { + ctx.notice(me, from.uid, "Commands: REGISTER [email], IDENTIFY .") + } + Some(other) => ctx.notice(me, from.uid, format!("Unknown command: {}. Try HELP.", other)), None => {} } }