docs: add a module-developer API reference (docs/api/) — Command/Module/ChanMode/UserMode traits, the Server API surface, per-entity Extensible state, and a first-module tutorial

This commit is contained in:
Jean Chevronnet 2026-08-13 02:55:56 +00:00
parent bd054f7721
commit d795d1a59c
7 changed files with 569 additions and 1 deletions

View file

@ -117,7 +117,8 @@ objects instead of a fragile plugin ABI. **Full design notes:**
## Extending
Three small extension points, each one file + one table line:
Three small extension points, each one file + one table line — full reference and
a tutorial in [`docs/api/`](docs/api/):
- **Commands** (`src/command.rs`, `src/coremods/`) — a handler with `name`,
`min_params`, `before_reg`, `handle(&mut Server, uid, params)`.

View file

@ -18,6 +18,7 @@ This folder is the reference manual. Start with whichever fits what you're doing
| [IRCv3](ircv3.md) | The advertised capabilities and the notable extensions. |
| [Anti-abuse & flood protection](anti-abuse.md) | The layered defenses, from the accept edge up to the application, and how they fit with kernel/upstream filtering. |
| [Deployment](deployment.md) | Running in production: release builds, a supervised service, log shipping, reverse proxies, and firewall hardening. |
| [**Module developer API**](api/) | Write your own commands, modes, and modules — the trait reference, the `Server` API, and a first-module tutorial. |
## At a glance

110
docs/api/README.md Normal file
View file

@ -0,0 +1,110 @@
# Module developer API
This is the reference for extending echoIRCd. The server has five extension
points, all ordinary Rust trait objects compiled into the binary — there is no
plugin ABI, no dynamic loading, and no `unsafe`:
| You want to… | Implement | Registered in | Reference |
|--------------|-----------|---------------|-----------|
| Add a command | [`Command`](commands.md) | `modules/mod.rs::module_commands()` | [commands](commands.md) |
| Add a channel/user mode | [`ChanMode`](modes.md) / [`UserMode`](modes.md) | `mode.rs::CHAN_MODES` / `USER_MODES` | [modes](modes.md) |
| Hook lifecycle events | [`Module`](modules.md) | `modules/mod.rs::default_modules()` | [modules](modules.md) |
| Store per-user / per-server state | [`Extensible`](server.md#per-entity-state) typemap | — | [server](server.md) |
| Call into the server | the [`Server`](server.md) API | — | [server](server.md) |
## The mental model — read this first
**Everything runs on one thread.** A single core thread owns every `User` and
`Channel`. Your command handlers, mode handlers, and module hooks are all called
on that thread with `&mut Server`. That means:
- **Your code is plain synchronous Rust.** No `async`, no `.await`, no `Send +
'static` futures, no `Arc<Mutex<…>>`. You read and mutate `Server` directly.
- **You must never block.** A slow operation (a KDF hash, a network call, a big
disk write) would freeze the whole server. Offload it — see
[off-core work](server.md#off-core-work) — and handle the result as an event.
- **State is safe by construction.** Users and channels are referenced by `Uid` /
channel-key handles, not pointers, so there are no dangling references.
## Project conventions
Modules in this codebase follow a few hard rules — match them:
1. **One module per file** in `src/modules/`. A module is self-contained; it does
not add fields to `Server` or `Config`.
2. **State goes in the `Extensible` typemap**, not in new struct fields — attach
per-user data to `User.ext`, per-server (and per-channel, keyed by name) data
to `Server.ext`. It is dropped automatically with its owner. See
[per-entity state](server.md#per-entity-state).
3. **Read settings through the config accessors** (`conf`, `conf_all`, `conf_num`,
`conf_bool`) — never hardcode a tunable value; expose it as a config key with a
literal default.
4. **Register in the table**, don't touch the parser or the dispatcher. Adding a
command / mode / module is one new file plus one line in a registration table.
5. **Original Rust only.** No `unsafe`, no C/FFI, no new dependencies, and no code
copied or translated from another project. `scripts/native-rust-guard.sh`
enforces this on every edit.
## Write your first module in five steps
A module that logs connects and quits — the canonical template
(`src/modules/snoop.rs`):
**1. Create the file** `src/modules/hello.rs`:
```rust
//! hello — a tiny example module.
use crate::module::Module;
use crate::server::Server;
use crate::Uid;
pub struct Hello;
impl Module for Hello {
fn name(&self) -> &'static str {
"hello"
}
fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) {
if let Some(u) = srv.users.get(&uid) {
srv.snotice(&format!("hello: {} connected", u.nick));
}
}
}
```
**2. Declare the file** in `src/modules/mod.rs`:
```rust
pub mod hello;
```
**3. Register the module** in the same file's `default_modules()`:
```rust
pub fn default_modules() -> Vec<Box<dyn Module>> {
vec![
// …existing modules…
Box::new(hello::Hello),
]
}
```
**4. (Only if it adds a command)** expose a `commands()` function from your module
and chain it into `module_commands()` — see [commands](commands.md).
**5. Build, test, and check:**
```sh
cargo build && cargo test
bash scripts/native-rust-guard.sh src/modules/hello.rs
```
That's it — `hello` is now a first-class part of the server.
## Where to go next
- [The `Module` trait](modules.md) — every lifecycle hook and how to block actions.
- [Commands](commands.md) — add a command and reply to clients.
- [Modes](modes.md) — add a channel or user mode.
- [The `Server` API](server.md) — sending, lookups, permissions, config, state,
and off-core work.

102
docs/api/commands.md Normal file
View file

@ -0,0 +1,102 @@
# The `Command` trait
A command is a stateless handler registered by name. Implement
`crate::command::Command`:
```rust
pub trait Command: Send {
fn name(&self) -> &'static str; // upper-case; also the registry key
fn min_params(&self) -> usize { 0 } // fewer args ⇒ core replies 461, skips you
fn before_reg(&self) -> bool { false } // may it run before registration?
fn handle(&self, srv: &mut Server, uid: Uid, params: &[String]) -> CmdResult;
}
pub enum CmdResult { Ok, Fail } // Fail is for your own bookkeeping/logging
```
The core does the boilerplate for you before `handle` is called:
- **Arity** — if the client sent fewer than `min_params` arguments, the core
replies `461 ERR_NEEDMOREPARAMS` and never calls you.
- **Registration gate** — unless `before_reg()` returns `true`, the command is
refused until the client has registered. Only handshake commands
(`NICK`/`USER`/`CAP`/`PING`/`QUIT` and the like) set `before_reg`.
- **Module pre-hooks**`on_pre_command` runs first and may `Deny` you.
Inside `handle`, `params` is the already-split argument list (the trailing
`:parameter` is a single element). Return `CmdResult::Ok` / `Fail`.
## A minimal command
```rust
use crate::command::{CmdResult, Command};
use crate::server::Server;
use crate::Uid;
pub struct Ping2;
impl Command for Ping2 {
fn name(&self) -> &'static str { "PING2" }
fn min_params(&self) -> usize { 1 }
fn handle(&self, srv: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let nick = srv.users.get(&uid).map(|u| u.nick.clone()).unwrap_or_default();
srv.send(uid, format!(":{} PONG {} :{}", srv.name, nick, params[0]));
CmdResult::Ok
}
}
```
## Replying to the client
Use the [`Server`](server.md#sending) helpers rather than building raw lines by
hand where you can:
- `srv.numeric(uid, code, rest)` — send a numeric reply (`005`, `461`, …).
- `srv.send(uid, line)` — send a fully-formed protocol line.
- `srv.fail(uid, command, code, desc)` / `warn(...)` / `note(...)` — IRCv3
standard replies (`FAIL` / `WARN` / `NOTE`) for clients that support them.
- `srv.snotice(msg)` — a server notice to opers (for staff-facing feedback).
## Registering
A module exposes its commands from a `commands()` function returning boxed
handlers:
```rust
pub fn commands() -> Vec<Box<dyn Command>> {
vec![Box::new(Ping2)]
}
```
Then chain it into `module_commands()` in `src/modules/mod.rs`:
```rust
pub fn module_commands() -> Vec<Box<dyn Command>> {
filter::commands()
// …existing chains…
.chain(mymod::commands())
.collect()
}
```
Command names are the registry key and must be unique and upper-case. The
`abbreviation` feature lets clients invoke a command by a unique prefix, so avoid
names that are prefixes of unrelated commands where it matters.
## Oper-only and gated commands
There is no separate "oper command" type — gate inside `handle`:
```rust
fn handle(&self, srv: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
if !srv.is_oper(uid) {
srv.numeric(uid, 481, ":Permission Denied- You're not an IRC operator");
return CmdResult::Fail;
}
// …privileged work…
CmdResult::Ok
}
```
See [the `Server` API](server.md) for lookups, permissions, config, and off-core
work.

105
docs/api/modes.md Normal file
View file

@ -0,0 +1,105 @@
# Writing modes
Modes are stateless `&'static` handler objects. The `MODE` parser dispatches each
letter to its handler, so adding a mode is a new handler plus one line in a table —
you never touch the parser. See the [mode reference](../modes.md) for the existing
letters (don't collide).
## User modes — the `UserMode` trait
```rust
pub trait UserMode: Sync {
fn letter(&self) -> char;
/// Apply +/- to the user; return true if it took effect (so the change is echoed).
fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool;
}
```
A user-mode handler is typically a zero-sized struct. Store the actual flag on the
user (existing flags live in `UserFlags`; module-specific state goes in
[`User.ext`](server.md#per-entity-state)).
```rust
struct BotMode;
impl UserMode for BotMode {
fn letter(&self) -> char { 'B' }
fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool {
match s.users.get_mut(&uid) {
Some(u) => { u.flags.bot = adding; true }
None => false,
}
}
}
static BOT: BotMode = BotMode;
```
Register it in `src/mode.rs` by adding `&BOT` to the `USER_MODES` slice.
## Channel modes — the `ChanMode` trait
```rust
pub trait ChanMode: Sync {
fn letter(&self) -> char;
/// Whether this sign consumes an argument (taken only if one remains).
fn wants_param(&self, adding: bool) -> bool;
/// A list mode (like +b): a no-arg query is just viewing, so it needn't
/// require operator rank. Default false.
fn is_list(&self) -> bool { false }
/// Apply +/- to channel `key` (display name `chan`) on behalf of `uid`.
fn apply(&self, s: &mut Server, chan: &str, key: &str, uid: Uid,
adding: bool, param: Option<&str>) -> Applied;
}
pub enum Applied {
No, // nothing to echo (no-op, rejected, or a list query)
Yes(Option<String>), // echo the change; Some(param) appends a parameter
}
```
Key points:
- `key` is the channel's lookup key (lower-cased name); `chan` is the display name.
Look the channel up with `s.channels.get_mut(key)`.
- `wants_param` controls whether the parser hands you a `param`. Return `true` for
a mode that takes an argument (a key, a limit, a mask); parameterless flags
return `false`.
- Return `Applied::Yes(None)` for a flag that flipped, `Applied::Yes(Some(p))` to
echo a parameter (e.g. the limit you set), or `Applied::No` if nothing changed
or you rejected it.
- **Enforce permissions yourself.** For an ordinary settable mode, check the
caller's rank (`s.rank(uid, key) >= RANK_OP`) before applying and reply/refuse
if they're not allowed.
```rust
struct NoCtcp;
impl ChanMode for NoCtcp {
fn letter(&self) -> char { 'C' }
fn wants_param(&self, _adding: bool) -> bool { false }
fn apply(&self, s: &mut Server, _chan: &str, key: &str, uid: Uid,
adding: bool, _param: Option<&str>) -> Applied {
if s.rank(uid, key) < crate::channels::RANK_OP { return Applied::No; }
match s.channels.get_mut(key) {
Some(c) if c.modes.no_ctcp != adding => { c.modes.no_ctcp = adding; Applied::Yes(None) }
_ => Applied::No,
}
}
}
static NO_CTCP: NoCtcp = NoCtcp;
```
Register it in `src/mode.rs` by adding `&NO_CTCP` to the `CHAN_MODES` slice, and
add its letter to the `CHANMODES=` group in the ISUPPORT string (`server.rs`) so
clients learn about it.
## List modes
Set `is_list() -> true` and `wants_param() -> true`. A no-argument use is a list
query (rank-free); an argument adds/removes an entry. The built-in list modes
(`+b`, `+e`, `+I`, …) share a common `ListMode` handler parameterised by kind —
follow that pattern for a new list.
## Named-mode access
Every channel mode is also reachable by long name through the `PROP` command
(`namedmodes`) without extra work on your part — the mapping is derived from the
registered handlers.

111
docs/api/modules.md Normal file
View file

@ -0,0 +1,111 @@
# The `Module` trait
A module hooks lifecycle events. Implement `crate::module::Module` on a struct and
register it in `default_modules()`. Every method has a default, so implement only
the hooks you need.
```rust
pub trait Module: Send {
fn name(&self) -> &'static str;
// pre-hooks — fired inline, can Deny the action
fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult { ModResult::Passthru }
fn on_pre_command(&mut self, srv: &mut Server, uid: Uid, cmd: &str, params: &[String]) -> ModResult { ModResult::Passthru }
fn on_pre_message(&mut self, srv: &mut Server, uid: Uid, target: &str, text: &str) -> ModResult { ModResult::Passthru }
// notify-hooks — informational, fired after the fact
fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) {}
fn on_post_command(&mut self, srv: &mut Server, uid: Uid, cmd: &str) {}
fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) {}
fn on_part(&mut self, srv: &mut Server, uid: Uid, chan: &str, reason: &str) {}
fn on_user_quit(&mut self, srv: &mut Server, uid: Uid, reason: &str) {}
fn on_tick(&mut self, srv: &mut Server) {}
}
```
Note the receiver is `&mut self`: unlike commands and modes (which are stateless
`&self` handlers), a module instance can hold its own fields. In practice, prefer
the [`Extensible` typemap](server.md#per-entity-state) for per-user / per-channel
state so it lives and dies with its owner; use `self` fields only for
module-global state.
## Pre-hooks (can deny)
Pre-hooks run **inline, before** the action they gate, and return a `ModResult`:
```rust
pub enum ModResult {
Passthru, // no opinion — let other modules and the core decide
Allow, // force-allow: skip the remaining checks
Deny, // block the action
}
```
| Hook | Fires | `Deny` effect |
|------|-------|---------------|
| `on_user_register` | last gate before a client finishes registration | refuses the connection |
| `on_pre_command` | before any command runs | swallows the command silently |
| `on_pre_message` | before a `PRIVMSG` / `NOTICE` is delivered | drops the message |
Return `Deny` to block, `Allow` to force it through (bypassing other checks), or
`Passthru` to abstain. When you `Deny`, send the user an explanation yourself
(e.g. `srv.numeric(...)` or `srv.fail(...)`), since the core just stops.
```rust
fn on_pre_message(&mut self, srv: &mut Server, uid: Uid, target: &str, text: &str) -> ModResult {
if text.contains("badword") && !srv.is_oper(uid) {
srv.numeric(uid, 404, &format!("{target} :Message blocked"));
return ModResult::Deny;
}
ModResult::Passthru
}
```
## Notify-hooks (informational)
Notify-hooks run **after** the event, drained from a queue once the triggering
command finishes. They can't block, but they get `&mut Server`, so they can act —
send lines, force a join, update state.
| Hook | Fires when |
|------|-----------|
| `on_user_connect` | a client has fully registered |
| `on_post_command` | after a command completes |
| `on_join` | a user joined a channel |
| `on_part` | a user left a channel |
| `on_user_quit` | a user is disconnecting (still exists during the call) |
| `on_tick` | the background timer, every `TICK_SECS` |
Because notify-hooks fire from a queue, a hook can itself cause more events (e.g.
force a join) without re-entering the module list — no surprises.
## Timed work
Use `on_tick` for periodic jobs (expiring entries, saving state, scoring). It runs
on the core thread, so keep it cheap; for a big write, hand it to
[`disk_write`](server.md#off-core-work) rather than blocking.
```rust
fn on_tick(&mut self, srv: &mut Server) {
let store = srv.ext.get_or_insert_with::<MyStore>(MyStore::default);
store.expire(crate::server::now());
// persist off-core so a slow disk can't stall the core:
srv.disk_write(format!("{}.mystore", srv.conf_path), store.serialize());
}
```
## Registering
Add your module to `src/modules/mod.rs`:
```rust
pub mod mymod; // declare the file
// …in default_modules():
Box::new(mymod::MyMod), // add to the vec
```
Order in the vec is the order pre-hooks are consulted; the first `Deny` (or
`Allow`) wins.
See [commands](commands.md) if your module also adds commands, and
[the `Server` API](server.md) for everything you can call from a hook.

138
docs/api/server.md Normal file
View file

@ -0,0 +1,138 @@
# The `Server` API
`Server` is the whole server state, passed as `&mut Server` to every command,
mode, and module hook. This is the surface you call from a module. Signatures are
abbreviated; see `src/server.rs` for the exact ones.
## The data model
- **`Uid`** — an opaque handle for a user (not a pointer). Look users up with it;
it can't dangle.
- **`srv.users: HashMap<Uid, User>`** — every local and remote user. A `User` has
`nick`, `ident`, `host`, `realname`, `account`, `flags`, `ext`, and more.
- **`srv.channels: HashMap<String, Channel>`** — keyed by the **lower-cased** name.
A `Channel` has its members, `modes` (a `ChanModes` struct), topic, and ban
lists.
- **`srv.name`, `srv.network`** — this server's name and the network name.
- **`srv.conf_path`** — the path to the loaded config file (useful for sibling
data files).
```rust
let nick = srv.users.get(&uid).map(|u| u.nick.clone());
let count = srv.channels.get(&key).map(|c| c.members.len());
```
## Per-entity state
Attach your own typed state through the `Extensible` typemap instead of adding
struct fields. It is keyed by Rust type and dropped automatically with its owner.
- **`srv.ext`** — per-server state. For **per-channel** state, key your value by
channel name inside a map stored here (channels themselves have no `ext`).
- **`srv.users[&uid].ext`** — per-user state.
```rust
#[derive(Default)]
struct Counter(u32);
let c = srv.ext.get_or_insert_with::<Counter>(Counter::default);
c.0 += 1;
if let Some(u) = srv.users.get_mut(&uid) {
u.ext.set(MyUserState { /* … */ });
}
```
`Extensible` methods: `get::<T>()`, `get_mut::<T>()`, `set::<T>(v)`,
`get_or_insert_with::<T>(f)`, `take::<T>()`.
## Configuration
Never hardcode a tunable — read it, with a literal default:
| Method | Returns |
|--------|---------|
| `srv.conf(key)` | `Option<&str>` — the single value, if set |
| `srv.conf_all(key)` | all values for a repeatable key |
| `srv.conf_bool(key, default)` | a `yes`/`on`/`true` flag |
| `srv.conf_num(key, default)` | any `FromStr` number |
```rust
let threshold = srv.conf_num("mymod_threshold", 8u32);
let enabled = srv.conf_bool("mymod", false);
for line in srv.conf_all("mymod_rule") { /* … */ }
```
## Sending
| Method | Sends |
|--------|-------|
| `srv.send(uid, line)` | a fully-formed protocol line to one user |
| `srv.numeric(uid, code, rest)` | a numeric reply (`005`, `461`, …) |
| `srv.fail(uid, cmd, code, desc)` / `warn(...)` / `note(...)` | IRCv3 standard replies |
| `srv.to_channel(key, line, except)` | a line to every member (optionally excluding one) |
| `srv.snotice(msg)` | a server notice to subscribed opers |
| `srv.announce(msg)` | a global notice to all users |
| `srv.notify_peers(uid, line, want)` | send to a user's common-channel peers whose caps match |
```rust
srv.numeric(uid, 481, ":Permission Denied- You're not an IRC operator");
srv.to_channel(&key, format!(":{} NOTICE {} :hi", srv.name, chan), Some(uid));
```
## Lookups & permissions
| Method | Result |
|--------|--------|
| `srv.is_oper(uid)` | is the user an IRC operator? |
| `srv.rank(uid, key)` | the user's channel rank (compare to `RANK_*`) |
| `srv.is_member(uid, key)` | is the user in the channel? |
| `srv.extban_active(uid, key, kind)` | does an acting extban of `kind` apply to them here? |
Rank constants (from `crate::channels`): `RANK_OWNER`, `RANK_ADMIN`, `RANK_OP`,
`RANK_HALFOP`, `RANK_VOICE`.
```rust
if srv.rank(uid, &key) >= crate::channels::RANK_OP { /* ops-only */ }
```
## Mutating users
| Method | Effect |
|--------|--------|
| `srv.change_host_ident(uid, new_ident, new_host)` | change a user's displayed ident/host (drives `chghost`) |
| `srv.oper_up(uid)` | mark a user as an operator |
| `srv.remove_user(uid, reason)` | disconnect a user cleanly |
## Off-core work
The core thread must never block. Offload slow work:
- **`srv.disk_write(path, contents)`** — fire-and-forget, coalescing, atomic
(temp + rename) file write. Use this for saving state; a slow disk can't stall
the core. **No result to handle** — the simplest offload.
- **`srv.spawn_crypto(closure)`** — run a bounded, CPU-heavy job (a KDF hash) on a
worker thread. Returns `false` if at capacity.
- **`srv.spawn_http(...)`** — an outbound HTTP request on a worker thread.
`spawn_crypto` / `spawn_http` deliver their result back as a core `Event`, so
wiring a *new* async result type touches the core event enum in `src/ircd.rs`;
`disk_write` needs nothing extra. Prefer `disk_write` for persistence and
`on_tick` for periodic jobs.
```rust
// good: persist off-core from a hook
srv.disk_write(format!("{}.mystore", srv.conf_path), store.serialize());
```
## Time
`crate::server::now()` → unix seconds. `iso_time(secs)` / `parse_iso(s)` convert to
and from ISO-8601 (used for `server-time` tags and timestamps).
## Rules of the road
- Do the work synchronously and quickly; **never block** — offload instead.
- Reference users/channels by `Uid` / key; don't cache references across events.
- Keep state in `ext`, read settings via `conf*`, and register in the tables. See
the [conventions](README.md#project-conventions).