commit f82ab0c70af0dd66105ba24777500ee9b4014a67 Author: Jean Date: Sat Jul 11 19:28:30 2026 +0000 Scaffold the federated services daemon Protocol-agnostic core: a Protocol trait maps raw server-to-server lines to a normalized event/action model, so the engine never touches a raw line and a new ircd is one new module. Adds event-log state (state is a fold over the log) and a service framework. First protocol impl links to an InspIRCd (insp4) uplink — completes the CAPAB/SERVER handshake, bursts, and introduces NickServ. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a3b37d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +config.toml diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6f26be8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,418 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fedserv" +version = "0.0.1" +dependencies = [ + "anyhow", + "serde", + "tokio", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..42408d3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "fedserv" +version = "0.0.1" +edition = "2021" +description = "Federated IRC services daemon (protocol-agnostic core, InspIRCd link first)" + +[dependencies] +tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..f02a720 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# fedserv + +A federated IRC services daemon in Rust. Protocol-agnostic core, InspIRCd link first. + +Clean-room — inspired by the ideas behind modern event-log/gossip services, not +derived from any project's source. + +## Design + +- **`proto/`** — the ircd link layer. A `Protocol` trait maps raw server-to-server + lines to/from a normalized `NetEvent`/`NetAction` model. The engine never touches + a raw line, so a new ircd = one new module. InspIRCd is the first. +- **`engine/`** — the services engine: live network `state`, an `EventLog` (every + persistent change is an `Event`; state is a fold over the log — this is what makes + replicating across nodes a later addition, not a rewrite), and the `Service` trait. +- **`services/`** — the pseudo-clients: NickServ first, then ChanServ / OperServ / … + +## Run + +```sh +cp config.example.toml config.toml # edit uplink + link password +cargo run -- config.toml +``` + +## Status + +Early scaffold: links to an InspIRCd uplink and introduces NickServ. Burst/mode +handling and account persistence are next. diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..10e55b5 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,12 @@ +# Copy to config.toml and edit. config.toml is gitignored (holds the link password). + +[uplink] +host = "127.0.0.1" +port = 7000 +password = "changeme" # must match the block on the uplink IRCd + +[server] +name = "services.example.net" +sid = "42S" # 3 chars, unique on the network +description = "Federated Services" +protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205 = insp3) diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..895d478 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,34 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub uplink: Uplink, + pub server: Server, +} + +#[derive(Debug, Deserialize)] +pub struct Uplink { + pub host: String, + pub port: u16, + pub password: String, +} + +#[derive(Debug, Deserialize)] +pub struct Server { + pub name: String, + pub sid: String, + pub description: String, + #[serde(default = "default_protocol")] + pub protocol: u32, +} + +fn default_protocol() -> u32 { + 1206 // InspIRCd 4 spanning-tree protocol (1205 = insp3) +} + +impl Config { + pub fn load(path: &str) -> anyhow::Result { + let raw = std::fs::read_to_string(path)?; + Ok(toml::from_str(&raw)?) + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs new file mode 100644 index 0000000..256e0a7 --- /dev/null +++ b/src/engine/mod.rs @@ -0,0 +1,61 @@ +pub mod service; +pub mod state; + +use crate::proto::{NetAction, NetEvent}; +use service::{Service, ServiceCtx}; +use state::{EventLog, Network}; + +pub struct Engine { + services: Vec>, + #[allow(dead_code)] + network: Network, + #[allow(dead_code)] + log: EventLog, +} + +impl Engine { + pub fn new(services: Vec>) -> Self { + Self { + services, + network: Network::default(), + log: EventLog::default(), + } + } + + // Sent right after the SERVER line: burst, introduce every service, endburst. + pub fn startup_actions(&self) -> Vec { + let mut out = vec![NetAction::Burst]; + for svc in &self.services { + out.push(NetAction::IntroduceUser { + uid: svc.uid().to_string(), + nick: svc.nick().to_string(), + ident: "services".to_string(), + host: svc.host().to_string(), + gecos: svc.gecos().to_string(), + }); + } + out.push(NetAction::EndBurst); + out + } + + pub fn handle(&mut self, event: NetEvent) -> Vec { + match event { + NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }], + 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. + fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec { + let mut ctx = ServiceCtx::default(); + for svc in self.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); + break; + } + } + ctx.actions + } +} diff --git a/src/engine/service.rs b/src/engine/service.rs new file mode 100644 index 0000000..d2ed7ba --- /dev/null +++ b/src/engine/service.rs @@ -0,0 +1,29 @@ +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. +pub trait Service: Send { + fn nick(&self) -> &str; + fn uid(&self) -> &str; + fn host(&self) -> &str { + "services.local" + } + fn gecos(&self) -> &str; + fn on_command(&mut self, from: &str, args: &[&str], ctx: &mut ServiceCtx); +} + +// What a service can do in response to a command, collected for the link to flush. +#[derive(Default)] +pub struct ServiceCtx { + pub actions: Vec, +} + +impl ServiceCtx { + pub fn notice(&mut self, from: &str, to: &str, text: impl Into) { + self.actions.push(NetAction::Notice { + from: from.to_string(), + to: to.to_string(), + text: text.into(), + }); + } +} diff --git a/src/engine/state.rs b/src/engine/state.rs new file mode 100644 index 0000000..0bfc20a --- /dev/null +++ b/src/engine/state.rs @@ -0,0 +1,39 @@ +use std::collections::HashMap; + +// Live network view the services reason about. +#[derive(Default)] +pub struct Network { + pub users: HashMap, // keyed by UID + pub channels: HashMap, +} + +pub struct User { + pub uid: String, + pub nick: String, + pub account: Option, +} + +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 }, +} + +#[derive(Default)] +pub struct EventLog { + pub events: Vec, +} + +impl EventLog { + pub fn append(&mut self, event: Event) { + self.events.push(event); + } +} diff --git a/src/link.rs b/src/link.rs new file mode 100644 index 0000000..ffcf5ab --- /dev/null +++ b/src/link.rs @@ -0,0 +1,43 @@ +use anyhow::Result; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; + +use crate::engine::Engine; +use crate::proto::Protocol; + +// One uplink session: connect, handshake + burst, then translate lines forever. +pub async fn run(mut proto: Box, mut engine: Engine, addr: &str) -> Result<()> { + let stream = TcpStream::connect(addr).await?; + let (read, mut write) = stream.into_split(); + let mut lines = BufReader::new(read).lines(); + + for line in proto.handshake() { + send(&mut write, &line).await?; + } + for action in engine.startup_actions() { + for line in proto.serialize(&action) { + send(&mut write, &line).await?; + } + } + + while let Some(line) = lines.next_line().await? { + tracing::debug!(dir = "<<", %line); + for event in proto.parse(&line) { + for action in engine.handle(event) { + for out in proto.serialize(&action) { + send(&mut write, &out).await?; + } + } + } + } + + tracing::warn!("uplink closed the connection"); + Ok(()) +} + +async fn send(write: &mut (impl AsyncWriteExt + Unpin), line: &str) -> Result<()> { + tracing::debug!(dir = ">>", %line); + write.write_all(line.as_bytes()).await?; + write.write_all(b"\r\n").await?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b12e68d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,43 @@ +mod config; +mod engine; +mod link; +mod proto; +mod services; + +use anyhow::Result; +use std::time::{SystemTime, UNIX_EPOCH}; + +use engine::Engine; +use proto::inspircd::InspIrcd; +use services::nickserv::NickServ; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "fedserv=debug".into()), + ) + .init(); + + let path = std::env::args().nth(1).unwrap_or_else(|| "config.toml".to_string()); + let cfg = config::Config::load(&path)?; + let ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + let proto = Box::new(InspIrcd::new( + cfg.server.name.clone(), + cfg.server.sid.clone(), + cfg.uplink.password.clone(), + cfg.server.protocol, + ts, + )); + + let services: Vec> = vec![Box::new(NickServ { + uid: format!("{}AAAAAA", cfg.server.sid), + })]; + let engine = Engine::new(services); + + let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port); + tracing::info!(server = %cfg.server.name, %addr, "linking to uplink"); + link::run(proto, engine, &addr).await +} diff --git a/src/proto/inspircd.rs b/src/proto/inspircd.rs new file mode 100644 index 0000000..069a61d --- /dev/null +++ b/src/proto/inspircd.rs @@ -0,0 +1,104 @@ +// InspIRCd spanning-tree link protocol. Handshake + UID + PING mirror the +// sequence in Network-Links (protocols/inspircd.py); mode/burst details get +// firmed up against a live insp4 uplink. +use super::{NetAction, NetEvent, Protocol}; + +pub struct InspIrcd { + sid: String, + name: String, + password: String, + protocol: u32, + ts: u64, +} + +impl InspIrcd { + pub fn new(name: String, sid: String, password: String, protocol: u32, ts: u64) -> Self { + Self { sid, name, password, protocol, ts } + } + + fn from_us(&self, cmd: String) -> String { + format!(":{} {}", self.sid, cmd) + } +} + +impl Protocol for InspIrcd { + fn handshake(&mut self) -> Vec { + vec![ + format!("CAPAB START {}", self.protocol), + format!("CAPAB CAPABILITIES :PROTOCOL={}", self.protocol), + "CAPAB END".to_string(), + format!("SERVER {} {} {} :{}", self.name, self.password, self.sid, "Federated Services"), + ] + } + + fn parse(&mut self, line: &str) -> Vec { + let (source, rest) = match line.strip_prefix(':') { + Some(s) => { + let mut it = s.splitn(2, ' '); + (Some(it.next().unwrap_or("").to_string()), it.next().unwrap_or("")) + } + None => (None, line), + }; + let mut tokens = rest.split(' '); + let cmd = match tokens.next() { + Some(c) => c, + None => return vec![], + }; + match cmd.to_ascii_uppercase().as_str() { + "SERVER" => vec![NetEvent::Registered], + "ENDBURST" => vec![NetEvent::EndBurst], + "PING" => { + let token = tokens + .next() + .unwrap_or("") + .trim_start_matches(':') + .to_string(); + vec![NetEvent::Ping { token, from: source }] + } + "PRIVMSG" => { + let to = tokens.next().unwrap_or("").to_string(); + vec![NetEvent::Privmsg { + from: source.unwrap_or_default(), + to, + text: trailing(rest), + }] + } + "QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }], + _ => vec![NetEvent::Unknown { line: line.to_string() }], + } + } + + fn serialize(&mut self, action: &NetAction) -> Vec { + match action { + NetAction::Burst => vec![self.from_us(format!("BURST {}", self.ts))], + NetAction::EndBurst => vec![self.from_us("ENDBURST".to_string())], + NetAction::Pong { token, from } => { + let dest = from.clone().unwrap_or_else(|| self.sid.clone()); + vec![self.from_us(format!("PONG {} {}", dest, token))] + } + NetAction::IntroduceUser { uid, nick, ident, host, gecos } => vec![self.from_us(format!( + "UID {uid} {ts} {nick} {host} {host} {ident} 0.0.0.0 {ts} +ioS :{gecos}", + uid = uid, ts = self.ts, nick = nick, host = host, ident = ident, gecos = gecos + ))], + NetAction::Privmsg { from, to, text } => { + vec![format!(":{} PRIVMSG {} :{}", from, to, text)] + } + NetAction::Notice { from, to, text } => { + vec![format!(":{} NOTICE {} :{}", from, to, text)] + } + NetAction::Raw(s) => vec![s.clone()], + } + } + + fn sid(&self) -> &str { + &self.sid + } +} + +// The IRC "trailing" argument: everything after the first " :", else the last word. +fn trailing(rest: &str) -> String { + match rest.find(" :") { + Some(i) => rest[i + 2..].to_string(), + None => rest.rsplit(' ').next().unwrap_or("").to_string(), + } +} diff --git a/src/proto/mod.rs b/src/proto/mod.rs new file mode 100644 index 0000000..e9cd068 --- /dev/null +++ b/src/proto/mod.rs @@ -0,0 +1,38 @@ +// Protocol abstraction. The engine only ever sees NetEvent / NetAction; raw +// server-to-server lines live entirely behind a Protocol impl. Adding another +// ircd later means one new module, engine untouched. +pub mod inspircd; + +// Normalized inbound facts, translated from the uplink's raw lines. +#[derive(Debug, Clone)] +pub enum NetEvent { + Registered, + EndBurst, + Ping { token: String, from: Option }, + Privmsg { from: String, to: String, text: String }, + Quit { uid: String }, + Unknown { line: String }, +} + +// Normalized outbound intents the engine wants performed on the network. +#[derive(Debug, Clone)] +pub enum NetAction { + Burst, + EndBurst, + Pong { token: String, from: Option }, + IntroduceUser { uid: String, nick: String, ident: String, host: String, gecos: String }, + Privmsg { from: String, to: String, text: String }, + Notice { from: String, to: String, text: String }, + Raw(String), +} + +pub trait Protocol: Send { + /// Lines to send immediately on connect (auth / capability negotiation). + fn handshake(&mut self) -> Vec; + /// One raw inbound line -> zero or more normalized events. + fn parse(&mut self, line: &str) -> Vec; + /// One normalized action -> the raw line(s) that realise it. + fn serialize(&mut self, action: &NetAction) -> Vec; + /// Our own server id, used as the source prefix for server-sourced lines. + fn sid(&self) -> &str; +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..c72e5a2 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1 @@ +pub mod nickserv; diff --git a/src/services/nickserv.rs b/src/services/nickserv.rs new file mode 100644 index 0000000..2ce42f0 --- /dev/null +++ b/src/services/nickserv.rs @@ -0,0 +1,25 @@ +use crate::engine::service::{Service, ServiceCtx}; + +pub struct NickServ { + pub uid: String, +} + +impl Service for NickServ { + fn nick(&self) -> &str { + "NickServ" + } + fn uid(&self) -> &str { + &self.uid + } + fn gecos(&self) -> &str { + "Nickname Services" + } + + fn on_command(&mut self, from: &str, args: &[&str], ctx: &mut ServiceCtx) { + 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)), + None => {} + } + } +}