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",
|
||||
"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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
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.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
|
|
|
|||
25
src/main.rs
25
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<Box<dyn engine::service::Service>> = 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<Box<dyn engine::service::Service>> = 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::<engine::db::LogEntry>(1024);
|
||||
let mut db = engine::db::Db::open("fedserv.db.jsonl", &cfg.server.sid);
|
||||
db.scram_iterations = cfg.server.scram_iterations;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue