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:
parent
596630df53
commit
e0bb3a1fea
9 changed files with 254 additions and 7 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
117
MODULES.md
Normal 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.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
8
example/Cargo.toml
Normal 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
70
example/src/lib.rs
Normal 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.")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
25
src/main.rs
25
src/main.rs
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue