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.
This commit is contained in:
Jean Chevronnet 2026-07-13 01:48:09 +00:00
parent 596630df53
commit e0bb3a1fea
No known key found for this signature in database
9 changed files with 254 additions and 7 deletions

8
Cargo.lock generated
View file

@ -286,6 +286,7 @@ dependencies = [
"base64", "base64",
"fedserv-api", "fedserv-api",
"fedserv-chanserv", "fedserv-chanserv",
"fedserv-example",
"fedserv-inspircd", "fedserv-inspircd",
"fedserv-nickserv", "fedserv-nickserv",
"hmac", "hmac",
@ -317,6 +318,13 @@ dependencies = [
"fedserv-api", "fedserv-api",
] ]
[[package]]
name = "fedserv-example"
version = "0.0.1"
dependencies = [
"fedserv-api",
]
[[package]] [[package]]
name = "fedserv-inspircd" name = "fedserv-inspircd"
version = "0.0.1" version = "0.0.1"

View file

@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["api", "inspircd", "chanserv", "nickserv"] members = ["api", "inspircd", "chanserv", "nickserv", "example"]
[package] [package]
name = "fedserv" name = "fedserv"
@ -12,6 +12,7 @@ fedserv-api = { path = "api" }
fedserv-inspircd = { path = "inspircd" } fedserv-inspircd = { path = "inspircd" }
fedserv-chanserv = { path = "chanserv" } fedserv-chanserv = { path = "chanserv" }
fedserv-nickserv = { path = "nickserv" } fedserv-nickserv = { path = "nickserv" }
fedserv-example = { path = "example" }
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] } tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

117
MODULES.md Normal file
View file

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

View file

@ -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 pseudo-clients, each a crate implementing `Service`. OperServ and others follow
the same shape. 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 ## Replication
Each log entry carries an `origin`, a per-origin `seq`, and a Lamport clock. Peers Each log entry carries an `origin`, a per-origin `seq`, and a Lamport clock. Peers

View file

@ -35,3 +35,8 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205
# [grpc.tls] # omit for plaintext (fine on a private/loopback hop) # [grpc.tls] # omit for plaintext (fine on a private/loopback hop)
# cert = "certs/node.crt" # cert = "certs/node.crt"
# key = "certs/node.key" # 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"]

8
example/Cargo.toml Normal file
View file

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

70
example/src/lib.rs Normal file
View file

@ -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 <nick>");
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 <nick>."),
Some(other) => ctx.notice(me, from.uid, format!("Unknown command \x02{other}\x02. Try \x02HELP\x02.")),
}
}
}

View file

@ -16,6 +16,28 @@ pub struct Config {
// directory. Absent = the RPC server does not start. // directory. Absent = the RPC server does not start.
#[serde(default)] #[serde(default)]
pub grpc: Option<Grpc>, pub grpc: Option<Grpc>,
// 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<String>,
}
impl Default for Modules {
fn default() -> Self {
Modules { services: default_services() }
}
}
fn default_services() -> Vec<String> {
vec!["nickserv".to_string(), "chanserv".to_string()]
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]

View file

@ -15,6 +15,7 @@ use tokio::sync::Mutex;
use engine::Engine; use engine::Engine;
use fedserv_chanserv::ChanServ; use fedserv_chanserv::ChanServ;
use fedserv_example::ExampleServ;
use fedserv_inspircd::InspIrcd; use fedserv_inspircd::InspIrcd;
use fedserv_nickserv::NickServ; use fedserv_nickserv::NickServ;
@ -39,16 +40,28 @@ async fn main() -> Result<()> {
ts, ts,
)); ));
let services: Vec<Box<dyn engine::service::Service>> = vec![ // Bring up the service modules named in [modules] (default NickServ +
Box::new(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<Box<dyn engine::service::Service>> = Vec::new();
if enabled("nickserv") {
services.push(Box::new(NickServ {
uid: format!("{}AAAAAA", cfg.server.sid), uid: format!("{}AAAAAA", cfg.server.sid),
guest_nick: cfg.server.guest_nick.clone(), guest_nick: cfg.server.guest_nick.clone(),
guest_seq: (ts % 100_000) as u32, guest_seq: (ts % 100_000) as u32,
}), }));
Box::new(ChanServ { }
if enabled("chanserv") {
services.push(Box::new(ChanServ {
uid: format!("{}AAAAAB", cfg.server.sid), 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::<engine::db::LogEntry>(1024); let (gossip_tx, _) = tokio::sync::broadcast::channel::<engine::db::LogEntry>(1024);
let mut db = engine::db::Db::open("fedserv.db.jsonl", &cfg.server.sid); let mut db = engine::db::Db::open("fedserv.db.jsonl", &cfg.server.sid);
db.scram_iterations = cfg.server.scram_iterations; db.scram_iterations = cfg.server.scram_iterations;