From d795d1a59c2cb76213b9626165dadb4be8a8466e Mon Sep 17 00:00:00 2001 From: reverse Date: Thu, 13 Aug 2026 02:55:56 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20add=20a=20module-developer=20API=20refe?= =?UTF-8?q?rence=20(docs/api/)=20=E2=80=94=20Command/Module/ChanMode/UserM?= =?UTF-8?q?ode=20traits,=20the=20Server=20API=20surface,=20per-entity=20Ex?= =?UTF-8?q?tensible=20state,=20and=20a=20first-module=20tutorial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- docs/README.md | 1 + docs/api/README.md | 110 ++++++++++++++++++++++++++++++++++ docs/api/commands.md | 102 ++++++++++++++++++++++++++++++++ docs/api/modes.md | 105 ++++++++++++++++++++++++++++++++ docs/api/modules.md | 111 ++++++++++++++++++++++++++++++++++ docs/api/server.md | 138 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 docs/api/README.md create mode 100644 docs/api/commands.md create mode 100644 docs/api/modes.md create mode 100644 docs/api/modules.md create mode 100644 docs/api/server.md diff --git a/README.md b/README.md index 72c1304..e5431f2 100644 --- a/README.md +++ b/README.md @@ -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)`. diff --git a/docs/README.md b/docs/README.md index f6fb171..0b704c3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..3693ebe --- /dev/null +++ b/docs/api/README.md @@ -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>`. 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> { + 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. diff --git a/docs/api/commands.md b/docs/api/commands.md new file mode 100644 index 0000000..21521a6 --- /dev/null +++ b/docs/api/commands.md @@ -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> { + vec![Box::new(Ping2)] +} +``` + +Then chain it into `module_commands()` in `src/modules/mod.rs`: + +```rust +pub fn module_commands() -> Vec> { + 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. diff --git a/docs/api/modes.md b/docs/api/modes.md new file mode 100644 index 0000000..43709b9 --- /dev/null +++ b/docs/api/modes.md @@ -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), // 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. diff --git a/docs/api/modules.md b/docs/api/modules.md new file mode 100644 index 0000000..680a4d4 --- /dev/null +++ b/docs/api/modules.md @@ -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::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. diff --git a/docs/api/server.md b/docs/api/server.md new file mode 100644 index 0000000..42a130e --- /dev/null +++ b/docs/api/server.md @@ -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`** — every local and remote user. A `User` has + `nick`, `ident`, `host`, `realname`, `account`, `flags`, `ext`, and more. +- **`srv.channels: HashMap`** — 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::default); +c.0 += 1; + +if let Some(u) = srv.users.get_mut(&uid) { + u.ext.set(MyUserState { /* … */ }); +} +``` + +`Extensible` methods: `get::()`, `get_mut::()`, `set::(v)`, +`get_or_insert_with::(f)`, `take::()`. + +## 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).