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.
This commit is contained in:
Jean Chevronnet 2026-07-11 19:28:30 +00:00
commit f82ab0c70a
No known key found for this signature in database
15 changed files with 890 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
config.toml

418
Cargo.lock generated Normal file
View file

@ -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",
]

13
Cargo.toml Normal file
View file

@ -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"] }

28
README.md Normal file
View file

@ -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.

12
config.example.toml Normal file
View file

@ -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 <link> 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)

34
src/config.rs Normal file
View file

@ -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<Self> {
let raw = std::fs::read_to_string(path)?;
Ok(toml::from_str(&raw)?)
}
}

61
src/engine/mod.rs Normal file
View file

@ -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<Box<dyn Service>>,
#[allow(dead_code)]
network: Network,
#[allow(dead_code)]
log: EventLog,
}
impl Engine {
pub fn new(services: Vec<Box<dyn Service>>) -> 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<NetAction> {
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<NetAction> {
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<NetAction> {
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
}
}

29
src/engine/service.rs Normal file
View file

@ -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<NetAction>,
}
impl ServiceCtx {
pub fn notice(&mut self, from: &str, to: &str, text: impl Into<String>) {
self.actions.push(NetAction::Notice {
from: from.to_string(),
to: to.to_string(),
text: text.into(),
});
}
}

39
src/engine/state.rs Normal file
View file

@ -0,0 +1,39 @@
use std::collections::HashMap;
// Live network view the services reason about.
#[derive(Default)]
pub struct Network {
pub users: HashMap<String, User>, // keyed by UID
pub channels: HashMap<String, Channel>,
}
pub struct User {
pub uid: String,
pub nick: String,
pub account: Option<String>,
}
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<Event>,
}
impl EventLog {
pub fn append(&mut self, event: Event) {
self.events.push(event);
}
}

43
src/link.rs Normal file
View file

@ -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<dyn Protocol>, 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(())
}

43
src/main.rs Normal file
View file

@ -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<Box<dyn engine::service::Service>> = 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
}

104
src/proto/inspircd.rs Normal file
View file

@ -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<String> {
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<NetEvent> {
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<String> {
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(),
}
}

38
src/proto/mod.rs Normal file
View file

@ -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<String> },
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<String> },
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<String>;
/// One raw inbound line -> zero or more normalized events.
fn parse(&mut self, line: &str) -> Vec<NetEvent>;
/// One normalized action -> the raw line(s) that realise it.
fn serialize(&mut self, action: &NetAction) -> Vec<String>;
/// Our own server id, used as the source prefix for server-sourced lines.
fn sid(&self) -> &str;
}

1
src/services/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod nickserv;

25
src/services/nickserv.rs Normal file
View file

@ -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 => {}
}
}
}