From e0bb3a1feaf01893924873fd8cbd797e9e0b3bab Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 01:48:09 +0000 Subject: [PATCH] modules: [modules] config + example service crate + SDK guide A [modules] block selects which services start (default: NickServ + ChanServ); main.rs constructs each compiled-in module only when named, so a node can run a subset and optional modules ship dormant. Add fedserv-example, a minimal read-only Service that depends on fedserv-api alone -- the template a new pseudo-client is copied from, enabled by adding "example" to the config. MODULES.md documents writing and registering a service or protocol module. --- Cargo.lock | 8 +++ Cargo.toml | 3 +- MODULES.md | 117 ++++++++++++++++++++++++++++++++++++++++++++ README.md | 3 ++ config.example.toml | 5 ++ example/Cargo.toml | 8 +++ example/src/lib.rs | 70 ++++++++++++++++++++++++++ src/config.rs | 22 +++++++++ src/main.rs | 25 +++++++--- 9 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 MODULES.md create mode 100644 example/Cargo.toml create mode 100644 example/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 150378e..e4afcd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,6 +286,7 @@ dependencies = [ "base64", "fedserv-api", "fedserv-chanserv", + "fedserv-example", "fedserv-inspircd", "fedserv-nickserv", "hmac", @@ -317,6 +318,13 @@ dependencies = [ "fedserv-api", ] +[[package]] +name = "fedserv-example" +version = "0.0.1" +dependencies = [ + "fedserv-api", +] + [[package]] name = "fedserv-inspircd" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 5532ce1..8e5b167 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["api", "inspircd", "chanserv", "nickserv"] +members = ["api", "inspircd", "chanserv", "nickserv", "example"] [package] name = "fedserv" @@ -12,6 +12,7 @@ fedserv-api = { path = "api" } fedserv-inspircd = { path = "inspircd" } fedserv-chanserv = { path = "chanserv" } fedserv-nickserv = { path = "nickserv" } +fedserv-example = { path = "example" } tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/MODULES.md b/MODULES.md new file mode 100644 index 0000000..9d12eab --- /dev/null +++ b/MODULES.md @@ -0,0 +1,117 @@ +# Writing a module + +fedserv is a small core plus a set of module crates. A module depends on one +crate — `fedserv-api` — and nothing else: not the engine, not the storage, not +the network code. That crate carries the traits a module implements and the +normalized vocabulary the engine speaks. If your module compiles against +`fedserv-api`, the core can run it. + +There are two kinds of module. + +- A **service** (a pseudo-client like NickServ) implements `Service`. +- A **protocol** (an ircd link like InspIRCd) implements `Protocol`. + +Both live in their own crate under the workspace. `example/` is a complete, +minimal service to copy from; `inspircd/` is the reference protocol. + +## A service, end to end + +**1. The crate.** One dependency: + +```toml +# mymod/Cargo.toml +[package] +name = "fedserv-mymod" +version = "0.0.1" +edition = "2021" + +[dependencies] +fedserv-api = { path = "../api" } +``` + +**2. The service.** A plain struct implementing `Service`: + +```rust +use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store}; + +pub struct MyServ { + pub uid: String, // assigned by the daemon +} + +impl Service for MyServ { + fn nick(&self) -> &str { "MyServ" } + fn uid(&self) -> &str { &self.uid } + fn gecos(&self) -> &str { "My Service" } + + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, store: &mut dyn Store) { + // args[0] is the command; reply by pushing onto ctx. + ctx.notice(&self.uid, from.uid, "hello"); + } +} +``` + +`on_command` is the whole surface. What you are handed: + +- **`from: &Sender`** — who sent it: `uid`, `nick`, and `account` (set when the + user is logged in). +- **`args: &[&str]`** — the message split on spaces; `args[0]` is the command. +- **`ctx: &mut ServiceCtx`** — the only way to affect anything. You push + *intents* and the engine performs them: `notice`, `login` / `logout`, + `channel_mode`, `kick`, `topic`, `invite`, `force_nick`, `send_email`, and the + deferred `defer_register` / `defer_password` (whose expensive key derivation + the engine runs off-thread). +- **`net: &dyn NetView`** — a read-only view of the live network: `uid_by_nick`, + `nick_of`, `host_of`, `account_of`, `is_op`, `channel_members`, `last_seen`. +- **`store: &mut dyn Store`** — the account and channel store. Reads hand back + plain views (`AccountView`, `ChannelView`, ...) that carry only non-secret + fields — never a password hash or SCRAM verifier. Writes are ordinary methods + (`set_email`, `register_channel`, `access_add`, ...). The event log, gossip and + credential material are not reachable from here, by design. + +A change you commit through `store` replicates to every other node the same way +an IRC-originated one does — you do not write any replication code. + +**3. Register it.** Add the crate to the workspace and to the daemon: + +```toml +# Cargo.toml +[workspace] +members = [..., "mymod"] + +[dependencies] +fedserv-mymod = { path = "mymod" } +``` + +Construct it in `src/main.rs` alongside the others, behind its config name: + +```rust +if enabled("mymod") { + services.push(Box::new(fedserv_mymod::MyServ { + uid: format!("{}AAAAAD", cfg.server.sid), // a stable, unique suffix + })); +} +``` + +**4. Enable it.** In the config: + +```toml +[modules] +services = ["nickserv", "chanserv", "mymod"] +``` + +Omitting `[modules]` starts NickServ and ChanServ. A name that isn't built in is +ignored. + +## A protocol module + +A protocol crate implements `Protocol`: it turns raw server-to-server lines into +the normalized `NetEvent`s the engine understands, and turns the engine's +`NetAction`s back into raw lines. The engine never sees a raw line, so supporting +another ircd is one new crate — see `inspircd/`. Wire it in `src/main.rs` where +`InspIrcd` is constructed. + +## What the SDK deliberately does not give you + +A module cannot reach the append-only log, the gossip layer, the storage engine, +or any credential material. It reads through views and writes through curated +methods. This is the boundary: a module can be wrong without being dangerous. diff --git a/README.md b/README.md index a3c9be7..47e874d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ workspace, each depending only on the `fedserv-api` SDK crate. pseudo-clients, each a crate implementing `Service`. OperServ and others follow the same shape. +Writing your own module — a service or a new ircd protocol — is documented in +[MODULES.md](MODULES.md); `example/` is a minimal service to copy from. + ## Replication Each log entry carries an `origin`, a per-origin `seq`, and a Lamport clock. Peers diff --git a/config.example.toml b/config.example.toml index 82604fb..99fd2f0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -35,3 +35,8 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205 # [grpc.tls] # omit for plaintext (fine on a private/loopback hop) # cert = "certs/node.crt" # key = "certs/node.key" + +# Which service modules to start. Omit this section for the built-in NickServ + +# ChanServ. Add "example" to also start the example template service. +# [modules] +# services = ["nickserv", "chanserv"] diff --git a/example/Cargo.toml b/example/Cargo.toml new file mode 100644 index 0000000..341a28c --- /dev/null +++ b/example/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "fedserv-example" +version = "0.0.1" +edition = "2021" +description = "A minimal example service module: the template a new pseudo-client is copied from." + +[dependencies] +fedserv-api = { path = "../api" } diff --git a/example/src/lib.rs b/example/src/lib.rs new file mode 100644 index 0000000..c82cadc --- /dev/null +++ b/example/src/lib.rs @@ -0,0 +1,70 @@ +//! A minimal example service module — the smallest complete `Service`, meant to +//! be copied when writing a new pseudo-client. It depends on nothing but the +//! `fedserv-api` SDK crate, reads through the `Store`/`NetView` traits, and +//! answers users by pushing notices onto the `ServiceCtx`. It never mutates the +//! store, so it is safe to enable anywhere. +//! +//! To run it: add `"example"` to `[modules] services` in the config. To ship a +//! real module, replace the commands below with your own and register the struct +//! in the daemon's `main.rs` (see MODULES.md). + +use fedserv_api::{human_time, NetView, Sender, Service, ServiceCtx, Store}; + +// A service is a plain struct. It is introduced to the network at burst and then +// receives the commands users message it. `uid` is assigned by the daemon. +pub struct ExampleServ { + pub uid: String, +} + +impl Service for ExampleServ { + // The nick, uid and gecos the pseudo-client is introduced with. + fn nick(&self) -> &str { + "ExampleServ" + } + fn uid(&self) -> &str { + &self.uid + } + fn gecos(&self) -> &str { + "Example Service (SDK template)" + } + + // One message from a user, already split into `args`. `from` is who sent it, + // `net` is a read-only view of the live network, `store` the account/channel + // store. Emit replies by pushing onto `ctx`; the engine sends them. + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, store: &mut dyn Store) { + let me = self.uid.as_str(); + match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { + // Reading the sender + the account store. `from.account` is set when + // the user is logged in; `store.account` hands back a view with only + // non-secret fields (never a password hash). + Some("WHOAMI") => match from.account.and_then(|a| store.account(a)) { + Some(acct) => { + let email = acct.email.as_deref().unwrap_or("(none)"); + let state = if acct.verified { "verified" } else { "unverified" }; + ctx.notice(me, from.uid, format!( + "You are \x02{}\x02, registered {} ({state}), email {email}.", + acct.name, human_time(acct.ts), + )); + } + None => ctx.notice(me, from.uid, "You are not logged in to an account."), + }, + // Reading the live network view. + Some("LOOKUP") => { + let Some(&nick) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: LOOKUP "); + return; + }; + match net.uid_by_nick(nick) { + Some(uid) => { + let host = net.host_of(uid).unwrap_or("*"); + let account = net.account_of(uid).unwrap_or("(not logged in)"); + ctx.notice(me, from.uid, format!("\x02{nick}\x02 is online — host {host}, account {account}.")); + } + None => ctx.notice(me, from.uid, format!("\x02{nick}\x02 is not online.")), + } + } + Some("HELP") | None => ctx.notice(me, from.uid, "Commands: \x02WHOAMI\x02, \x02LOOKUP\x02 ."), + Some(other) => ctx.notice(me, from.uid, format!("Unknown command \x02{other}\x02. Try \x02HELP\x02.")), + } + } +} diff --git a/src/config.rs b/src/config.rs index c4663a5..675a051 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,28 @@ pub struct Config { // directory. Absent = the RPC server does not start. #[serde(default)] pub grpc: Option, + // Which service modules to start. Absent = the built-in NickServ + ChanServ. + #[serde(default)] + pub modules: Modules, +} + +// The service modules to bring up at burst. Each name maps to a compiled-in +// module crate the daemon knows how to construct (see main.rs). Names not built +// in are ignored. +#[derive(Debug, Deserialize)] +pub struct Modules { + #[serde(default = "default_services")] + pub services: Vec, +} + +impl Default for Modules { + fn default() -> Self { + Modules { services: default_services() } + } +} + +fn default_services() -> Vec { + vec!["nickserv".to_string(), "chanserv".to_string()] } #[derive(Debug, Deserialize, Clone)] diff --git a/src/main.rs b/src/main.rs index d46ec8c..e1b54c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ use tokio::sync::Mutex; use engine::Engine; use fedserv_chanserv::ChanServ; +use fedserv_example::ExampleServ; use fedserv_inspircd::InspIrcd; use fedserv_nickserv::NickServ; @@ -39,16 +40,28 @@ async fn main() -> Result<()> { ts, )); - let services: Vec> = vec![ - Box::new(NickServ { + // Bring up the service modules named in [modules] (default NickServ + + // ChanServ). Each keeps a fixed uid suffix so its identity is stable no + // matter which others are enabled. + let enabled = |name: &str| cfg.modules.services.iter().any(|s| s == name); + let mut services: Vec> = Vec::new(); + if enabled("nickserv") { + services.push(Box::new(NickServ { uid: format!("{}AAAAAA", cfg.server.sid), guest_nick: cfg.server.guest_nick.clone(), guest_seq: (ts % 100_000) as u32, - }), - Box::new(ChanServ { + })); + } + if enabled("chanserv") { + services.push(Box::new(ChanServ { uid: format!("{}AAAAAB", cfg.server.sid), - }), - ]; + })); + } + if enabled("example") { + services.push(Box::new(ExampleServ { + uid: format!("{}AAAAAC", cfg.server.sid), + })); + } let (gossip_tx, _) = tokio::sync::broadcast::channel::(1024); let mut db = engine::db::Db::open("fedserv.db.jsonl", &cfg.server.sid); db.scram_iterations = cfg.server.scram_iterations;