Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

docs(leveva): record P11 slice 53 (WebSocket transport)

PLAN.md P11 row 52->53; full slice entry in docs/progress-log/p11.md; the plan
file; and the websocket.md IRCv3 spec this implements (previously untracked).

+302 -1
+1 -1
PLAN.md
··· 90 90 | **P8 — Delete the C + retire the oracle harness ✅ DONE** (per-sub-phase: [`PLAN-P8-progress.md`](PLAN-P8-progress.md) + [`docs/progress-log/p8.md`](docs/progress-log/p8.md)) | remaining C (the ~25 variadic sender trampolines + residual data globals) + `ircd-sys` C build + `ircd-golden` (L2) + `ircd-testkit`/`cref_*` archives (L1) + the frozen reference‑C tree | With all C _logic_ already Rust (P7 exit), delete the last C: replace the variadic sender trampolines with a non‑variadic Rust sender API at every call site (faithful `wire!`/`reply_one!`/`format!` first; leveva typed messages adopted alongside the IRC senders), drop the residual data globals via the data‑symbol seam (bindgen `extern` decls resolve to `#[no_mangle]` Rust defs at link), then drop **all** C compilation from `ircd-sys` (no more `cc::Build`), retire the differential harness (`cref_*`/L1 `ircd-testkit`/`ircd-golden` L2 can no longer be built once the oracle is gone), and **migrate the load‑bearing tests into `ircd-common`** as self‑contained Rust tests (own fixtures, no `cref_` oracle). **Progress (P8a–P8r — full entries in the progress logs):** **every variadic sender trampoline is now Rust** (P8a–P8m, incl. the keystones `sendto_one`/`sendto_flag`/`esendto_*`), and the five data‑global `.o`s are dropped outright (`s_auth.o` iauth globals P8n, `send.o` P8o, `s_bsd.o` `local[]`/`highest_fd`/… P8p, `ircd.o` `me`/`client`/timers/… P8q). **P8r dropped the last C‑logic TU `support.o`** — `dgets`/`make_isupport` ported to Rust + the `ipv6string`/`minus_one` data globals defined as `#[no_mangle]` statics (`snprintf_append` is dead in Rust — WHOX is field‑by‑field — so it died with the `.o`; `irc_sprintf` is deleted not ported, in P11). **Every ircd C TU is now Rust:** the link set holds only the generated `version.o` + the L1 harness's `ctruth.o`. **P8s migrated all 115 L1 `cref_` differentials into self‑contained `ircd-common` `insta` snapshot tests** (drive only the Rust port via `link_anchor()`, no oracle; soundness via the capture chain) — the suite is green + 0 warnings. **P8t ported the last *generated* C TU `version.c` to Rust** (`ircd-common/src/version.rs`): the data globals `generation`/`creation`/`pass_version`/`infotext`/`isupport` are now `#[no_mangle]` statics via the data‑symbol seam — `generation`/`creation` sourced from crate metadata at build time (`CARGO_PKG_VERSION` + an `ircd-common/build.rs` UTC build stamp), `pass_version`/`infotext` copied verbatim. `version.c.SH` generation + the `version.o` compile/archive are gone from `ircd-sys/build.rs`; `libircd_c.a` now holds **only** the L1 harness `ctruth.o`. (`creation` is now genuinely build‑time volatile, so the golden canonicalizer masks RPL_CREATED 003, matching the existing 371 Birth‑Date rule.) **P8u (FINAL) retired the oracle.** Ran the differential suite a last time green — L1 `ircd-testkit` all pass; L2 `ircd-golden` 86 pass + only the documented reference‑C‑garbage `s_serv_stats` flake (where Rust is the *correct* side) — then **dropped all C compilation** from `ircd-sys/build.rs`: the `make` object build, the `cref_*`/`ctruth.o`/`res.o` recompiles, the `libircd_c.a` archive, and the whole‑archive link + `-lz`/`-lm`/`-lcrypt` (none needed by Rust — `pow` resolves from glibc 2.29+). `build.rs` now only runs `configure` + bindgen over the C *headers* (the struct view `ircd-common` still uses until P9–P12) and expands the install‑path Makefile vars. `ircd-testkit` (L1) + `ircd-golden` (L2) are `exclude`d from the workspace (mothballed, git‑recoverable); the `ctruth.c`/`layout.rs` drift‑net is deleted. **The workspace is now 100% Rust — zero C TUs compiled.** **P8v (literal end of C) deleted the C *source tree* itself + retired `ircd-sys`:** froze the generated `bindings.rs` into a committed `ircd-common/src/bindings.rs` (a self‑alias `extern crate self as ircd_sys;` keeps every `ircd_sys::bindings::*` path resolving, zero src churn) + the install‑path consts as literals, then deleted `ircd-sys`, the mothballed `ircd-testkit`/`ircd-golden` oracle crates, the `iauth-rs` C‑iauth differential, and **all** C source (`common/ ircd/ iauth/ support/ contrib/ cbuild/ configure .clang-format* clangformat.yml`). `ircd-rs` calls `ircd_common::ircd::c_ircd_main` directly. **Not one line of C remains in the repo.** | **MET. Final differential run green; oracle mothballed; `cargo build --workspace` 0 warnings links pure Rust; `cargo test --workspace` 695 pass / 0 fail (P8v; was 698 before deleting the C‑oracle tests); the `ircd-common` snapshot tests carry the reference‑C correctness forward. C source tree deleted — repo is 100% Rust at the file level.** | 91 91 | **P9 — std‑library cleanup ❌ RETIRED (2026‑06‑08)** | — (was: whole Rust tree) | **Skipped by decision — superseded by the `leveva` greenfield.** P9 assumed an *in‑place* idiomatic transformation of the mechanical port (`MyMalloc`/`MyFree`→`Box`/`Vec`, `dbuf` freelist→owned, raw `libc`→`std`/`nix`, `[c_char;N]`→`[u8;N]`). Because the idiomatic end‑state is now a separate greenfield crate (`leveva`), `ircd-common` is throwaway code (the oracle, deleted at parity), so polishing it earns nothing. Nothing was implemented; the row is kept for provenance. | n/a — retired. | 92 92 | **P10 — `leveva` greenfield foundations 🔶 IN PROGRESS** ([`docs/progress-log/p10.md`](docs/progress-log/p10.md)) | new `leveva` crate (lib) + `leveva-integration` differential tests | The idiomatic‑Rust product, built from scratch (not transformed from the port). **Done so far:** RFC 1459 case‑folding (`casemap`); typed identifier strings (`IrcStr`/`IrcString` + `Nick`/`ChanName`/`Uid`/`Sid`/`Cid`/`ServerName`/`UserName`/`HostName` validating newtypes via `ident_newtype!`); `Message`/`MessageBuilder`; the full `Numeric` reply/error enum (184 codes, discriminant = wire code); a generic safe `patricia` trie; glob `matching`; `mode`; KDL `config` (model/parse/password/privilege); `rustls`/`tokio` `tls` endpoint with hot reload; `ident`. The async boot path (`main.rs`) binds listeners + watches certs but currently drops accepted connections — no protocol layer yet. Idiomatic Rust allowed everywhere; pulls real crates (tokio/rustls/kdl/nom/clap). | `cargo test -p leveva` green + clippy clean per module; `leveva-integration` pins each greenfield reimplementation against `ircd-common` (the oracle) within their shared domain (documented divergences asserted explicitly). | 93 - | **P11 — `leveva` protocol layer → feature parity 🔶 IN PROGRESS** ([`docs/progress-log/p11.md`](docs/progress-log/p11.md)) | `leveva` (client/server state machine + handlers) | The bulk of the remaining work: the connection + registration state machine, the post‑registration command handlers (JOIN/PRIVMSG/MODE/KICK/TOPIC/WHO/WHOIS/…), channels + channel/user modes, the S2S link/burst protocol (UID‑based: UNICK/NJOIN/SAVE/EOB), and `leveva-iauth` (native async auth — dnsbl/socks/webproxy/pipe/ident). Each slice is built idiomatically (owned per‑connection state, typed identifiers end‑to‑end, `Result`/`thiserror`, `format!`/typed builders — **no** `sprintf`/global `buf`), unit‑tested + boot‑golden + proptest‑fuzzed, and **differentially pinned against `ircd-common`** (or `iauth-rs` for auth) wherever a pure oracle entry point exists — structural skeleton + documented divergences, since leveva emits a clean modern burst rather than a byte copy. **Done: 52 slices (45–47 = the parity‑matrix slices: channel‑lifecycle inverses, burst/netsplit teardown inverses, then WHO/WHOIS/WHOX; 48 = the first IRCv3 slice — `CAP` capability negotiation framework + `cap-notify`; 49 = registration edge cases — `PASS` command + the connection‑block password gate against `allow` rules; 50 = STATS completion — `STATS k`/`K` active k‑line reporting (`216`) from the live `KlineStore`; 51 = the first IRCv3 behavior cap — `multi-prefix` (all `@+` status sigils in NAMES/WHO/WHOIS when negotiated); 52 = the first **live two‑node link integration** — two real `leveva` subprocesses linked by a real operator `CONNECT`, asserting `PRIVMSG`/`TOPIC`/`MODE` relay in both directions, exercising the outbound dial path (`command::connect`→`dial_server`→`build_link_greeting`) no hand‑coded `Peer` test reaches) — full per‑slice detail in [`docs/progress-log/p11.md`](docs/progress-log/p11.md).** | Per‑slice: leveva boots and serves the slice; leveva golden snapshot + proptest green; the `leveva-integration` differential agrees with `ircd-common` on the shared skeleton with divergences asserted. Across the eventual matrix: registration, channels, WHO/WHOIS/WHOX, OPER/STATS, server burst, netsplit, rehash, restart; iauth end‑to‑end. | 93 + | **P11 — `leveva` protocol layer → feature parity 🔶 IN PROGRESS** ([`docs/progress-log/p11.md`](docs/progress-log/p11.md)) | `leveva` (client/server state machine + handlers) | The bulk of the remaining work: the connection + registration state machine, the post‑registration command handlers (JOIN/PRIVMSG/MODE/KICK/TOPIC/WHO/WHOIS/…), channels + channel/user modes, the S2S link/burst protocol (UID‑based: UNICK/NJOIN/SAVE/EOB), and `leveva-iauth` (native async auth — dnsbl/socks/webproxy/pipe/ident). Each slice is built idiomatically (owned per‑connection state, typed identifiers end‑to‑end, `Result`/`thiserror`, `format!`/typed builders — **no** `sprintf`/global `buf`), unit‑tested + boot‑golden + proptest‑fuzzed, and **differentially pinned against `ircd-common`** (or `iauth-rs` for auth) wherever a pure oracle entry point exists — structural skeleton + documented divergences, since leveva emits a clean modern burst rather than a byte copy. **Done: 52 slices (45–47 = the parity‑matrix slices: channel‑lifecycle inverses, burst/netsplit teardown inverses, then WHO/WHOIS/WHOX; 48 = the first IRCv3 slice — `CAP` capability negotiation framework + `cap-notify`; 49 = registration edge cases — `PASS` command + the connection‑block password gate against `allow` rules; 50 = STATS completion — `STATS k`/`K` active k‑line reporting (`216`) from the live `KlineStore`; 51 = the first IRCv3 behavior cap — `multi-prefix` (all `@+` status sigils in NAMES/WHO/WHOIS when negotiated); 52 = the first **live two‑node link integration** — two real `leveva` subprocesses linked by a real operator `CONNECT`, asserting `PRIVMSG`/`TOPIC`/`MODE` relay in both directions, exercising the outbound dial path (`command::connect`→`dial_server`→`build_link_greeting`) no hand‑coded `Peer` test reaches; 53 = the first **transport** slice — **WebSocket support** (IRCv3 `websocket`) served by **axum** at `/socket`, a nested `websocket{}` block on a `listen{}` port, the leveva‑native `+W` umode + WHOIS `320` for ws clients (propagated network‑wide via `SEND_UMODES`/`UNICK`), pure framing/negotiation helpers in `leveva::websocket` + a real‑client end‑to‑end golden, plus `HELP USER_MODES`/`CHANNEL_MODES` reference pages) — full per‑slice detail in [`docs/progress-log/p11.md`](docs/progress-log/p11.md).** | Per‑slice: leveva boots and serves the slice; leveva golden snapshot + proptest green; the `leveva-integration` differential agrees with `ircd-common` on the shared skeleton with divergences asserted. Across the eventual matrix: registration, channels, WHO/WHOIS/WHOX, OPER/STATS, server burst, netsplit, rehash, restart; iauth end‑to‑end. | 94 94 | **P12 — Retire the mechanical port; `leveva` is the product** | delete `ircd-common`/`ircd-rs` + C‑era `iauth-rs` scaffolding; workspace = `leveva` | Once `leveva` is at parity (P11 green across the matrix), run the differential suite a final time, then **delete the mechanical port** (`ircd-common`, `ircd-rs`) and fold/replace `iauth-rs` with leveva‑native auth. The `leveva-integration` oracle tests retire with their oracle; the load‑bearing characterizations carry forward as self‑contained `leveva` tests. Workspace becomes just `leveva` (+ any sub‑crates it wants). This is the literal end of the strangler — same lifecycle the C tree had at P8. | Final differential run green; `ircd-common`/`ircd-rs` deleted; `cargo test -p leveva` carries the behavioral guarantees forward; minimal `unsafe`; clean clippy. | 95 95 96 96 ---
+68
docs/progress-log/p11.md
··· 3359 3359 **Gate:** `cargo build --workspace` 0 warnings; `cargo clippy -p leveva --tests` clean; 3360 3360 `integration_two_node` (1) + `link_greeting_proptest` (3) green; siblings `golden_s2s_relay` 3361 3361 / `relay_proptest` / `s2s_proptest` still green. 3362 + 3363 + ## 2026-06-10 — Slice 53: WebSocket transport (axum) + `+W` umode + WHOIS 320 3364 + 3365 + The first **transport** slice (every prior slice spoke raw TCP/TLS): browser/web IRC 3366 + clients connect over WebSocket per the IRCv3 `websocket` spec, served by **axum** at the 3367 + HTTP path `/socket`. A `listen{}` port becomes a WebSocket port via a nested `websocket{}` 3368 + block; clients that arrive over it carry a leveva-native `+W` user mode, surfaced in WHOIS 3369 + as a `320` line. 3370 + 3371 + - **Division of labour (the user's instruction).** axum / tokio-tungstenite own the 3372 + WebSocket protocol — the HTTP upgrade handshake, frame masking, fragmentation, ping/pong, 3373 + close codes. leveva implements only the IRC-over-WS *semantics*, faithful to InspIRCd's 3374 + `m_websocket.cpp` (insp4): one inbound frame = one IRC line (strip embedded CR/LF, append 3375 + `\r\n`); one **outbound frame per line** (split on `\n`, drop **all** CR, Text frame with 3376 + `U+FFFD` repair or Binary frame); subprotocol negotiation (`binary.ircv3.net` / 3377 + `text.ircv3.net` by client preference, else `default-mode`); the `Origin` allow-list and 3378 + the `X-Real-IP`/`X-Forwarded-For` proxy-trust check. 3379 + - **`leveva::websocket` (pure, `websocket/mod.rs`).** `negotiate` / `decode_incoming` / 3380 + `frame_lines` / `origin_allowed` / `proxy_trusted` / `cidr_contains` — no axum, no socket, 3381 + so they are exhaustively unit- and proptest-tested. `FrameMode`/`Negotiation`/`OutFrame`. 3382 + - **`leveva::websocket::server` (`websocket/server.rs`).** The axum/hyper glue: a per-listener 3383 + `Router` with `GET /socket` → `ws_handler` (origin 403, negotiation 400-on-reject, real-host 3384 + resolution, `WebSocketUpgrade` echoing the agreed subprotocol, 16 KiB max message), each 3385 + connection served via `hyper_util`'s `serve_connection_with_upgrades` over the existing 3386 + stream (the hot-reloadable `TlsEndpoint` for `wss://`). `serve_ws` drives the upgraded 3387 + socket through the **same `Session` state machine** as the TCP/TLS path — a dedicated 3388 + `select!` over socket / mailbox / keepalive / auth — flagged `set_websocket(true)` so 3389 + registration adds `+W`; one frame per outbound line via `SinkExt::send` (correct flush, 3390 + unlike a hand-rolled `AsyncWrite`-over-`Sink` adapter). To keep the server a clean lib 3391 + submodule, `tls.rs` was promoted to `leveva::tls` (it reached for no binary types); the 3392 + binary's accept loop now branches on the listener's `websocket` config. 3393 + - **`+W` user mode (`mode.rs`).** A leveva-native `UserMode::Websocket` (`'W'`, bit `0x0040`), 3394 + appended last so the oracle modes' bits/order are untouched; no C oracle (the oracle 3395 + `user_modes[]` has no `W`). Seeded at registration for WebSocket sessions and mirrored to 3396 + the registry. **Per the user's call it propagates network-wide**: it joins `SEND_UMODES` 3397 + (oracle 61 + `0x40` = 125) and the introduction burst already renders it (`UNICK`), so a 3398 + remote WebSocket user's WHOIS shows the same `320` a local one does. Server-set-only — a 3399 + client `+W`/`-W` is silently ignored (no `501`), like `+o`/`+r`/`+a`. It also joins the 3400 + server's advertised umode list (`RPL_MYINFO` 004 + ISUPPORT → `oOiwraW`). 3401 + - **WHOIS `320`.** `320 RPL_WHOISEXTRA :is connected via WebSocket`, emitted after the `313` 3402 + oper block when the target carries `+W` (the catch-all WHOIS slot the RPL_WHOISTLS alias 3403 + uses). Fires for remote WebSocket users too. 3404 + - **Config (`websocket{}` block).** `origins` (Origin globs; empty ⇒ any, warned), 3405 + `proxy-ranges` (peer IP glob/CIDR trusted for the forwarded-IP headers; a single 3406 + space-delimited string also accepted, à la InspIRCd `proxyranges`), `default-mode` 3407 + (`text`|`binary`|`reject`, default `text`). Bare `websocket` enables with defaults; 3408 + `websocket #false` disables. `wss://` reuses the existing `tls`/`tls-cert`/`tls-key`. 3409 + - **Tests.** Unit: mode round-trips/render, WHOIS ordering (`311 312 301 313 320 318`), 3410 + registration `+W` seeding (+ inverse), MODE server-only guard, config parse edge cases, 3411 + the pure helpers. **Fuzz** (`websocket_proptest.rs`): one-frame↔one-line roundtrip, frames 3412 + never carry CR/LF, frame count = non-empty lines, negotiate only echoes an offered 3413 + subprotocol, origin/proxy totality + CIDR boundaries. **Integration golden** 3414 + (`golden_websocket.rs`): a real `tokio-tungstenite` client against a booted leveva drives 3415 + the whole `/socket` stack end to end — text subprotocol negotiated+echoed, `001` as text 3416 + frames, WHOIS `320`, `MODE` shows `+W`; a second test drives the binary subprotocol and 3417 + asserts binary frames. 3418 + - **HELP reference pages.** `HELP USER_MODES` / `HELP CHANNEL_MODES` — table-formatted mode 3419 + catalogues `MODE.md` now delegates to (readable on markdown-rendering web clients). The 3420 + HELP system gained topic pages (allow `_` in lookups, an allowlist keeping the orphan-page 3421 + invariant honest). 3422 + - **Out of scope:** native WS ping keepalive (`nativeping`) — leveva keeps IRC-level PING; 3423 + per-message-deflate; legacy no-subprotocol autodetect beyond `default-mode`; a single 3424 + `/socket` path only. 3425 + - **Plan:** `docs/superpowers/plans/2026-06-10-p11-slice53-websocket.md`. 3426 + 3427 + **Gate:** `cargo build --workspace` 0 warnings; `cargo clippy -p leveva --tests` clean; 3428 + `cargo test -p leveva` green (unit + `websocket_proptest` + `golden_websocket` + all prior 3429 + slices, with the `004` umode-list snapshots updated to `oOiwraW`).
+54
docs/rfcs/ircv3/websocket.md
··· 1 + --- 2 + title: WebSocket support 3 + layout: spec 4 + copyrights: 5 + - 6 + name: Shivaram Lingamneni 7 + period: 2021 8 + email: slingamn@cs.stanford.edu 9 + 10 + --- 11 + 12 + ## Introduction 13 + 14 + This specification describes a mechanism for WebSocket-based clients (in particular, web-based IRC clients running in the browser) to connect to IRC networks. 15 + 16 + ## WebSocket features and encoding 17 + 18 + WebSocket is a message-based protocol, i.e., it handles message fragmentation and termination internally. It offers two message types, binary and text; each has advantages and disadvantages as a transport for IRC. Binary messages can transport arbitrary bytes and therefore any valid IRC line, while text messages must consist entirely of UTF-8 data. Consequently, binary frames are necessary to fully support IRC communities that use encodings other than UTF-8. On the other hand, given that web browsers are pushing increasingly towards UTF-8 as the default encoding for HTML5 documents and scripting languages, text messages make it simpler to achieve a correct implementation on the client side. 19 + 20 + We define two [WebSocket subprotocols](https://tools.ietf.org/html/rfc6455#section-1.9): `binary.ircv3.net` and `text.ircv3.net`. Servers implementing this specification MUST support both subprotocols in standard configurations. Clients implementing this specification MUST initially request one or both of these subprotocols, in order of preference (most preferred first). The server MUST accept any subprotocol it supports, respecting the client's order of preference. If no subprotocol can be agreed on, the server MAY continue connection establishment without sending the `Sec-WebSocket-Protocol` header. 21 + 22 + If `binary.ircv3.net` is negotiated, then client and server will exchange binary messages; if `text.ircv3.net` is negotiated, then they will exchange text messages. If no subprotocol is successfully negotiated, server and client MAY implement fallbacks designed for compatibility with legacy software; the exact behavior is left implementation-defined. 23 + 24 + In all cases, the message format is the same: each message MUST consist of a single IRC line, except that servers and clients MUST NOT include trailing `\r` or `\n` characters in the message. 25 + 26 + Maximum message size is reduced by two bytes accordingly (ie. to 510 bytes by default for the non-tags part of a message), to account for the server automatically inserting `\r\n`. 27 + 28 + Servers MUST NOT relay non-UTF-8 content to clients using text messages, since this may result in those clients being disconnected by the browser. Servers MAY replace non-UTF-8 bytes with the UTF-8 encoding of the Unicode replacement character `�` (`U+FFFD`). 29 + 30 + ## Client example 31 + ~~~ 32 + let socket = new WebSocket('wss://ws.example.org', ['text.ircv3.net']); 33 + socket.addEventListener('open', () => { 34 + socket.send('USER 0 0 0 0'); 35 + socket.send('NICK bob'); 36 + }); 37 + socket.addEventListener('message', event => processLine(event.data)); 38 + ~~~ 39 + 40 + ## Security considerations 41 + 42 + Clients and servers SHOULD impose limits on the maximum size of messages they will accept, in order to prevent denial-of-service attacks. The limits SHOULD reflect the increased total line lengths allowed by the [`message-tags`](../extensions/message-tags.html) specification. 43 + 44 + ## Other implementation considerations 45 + 46 + This section is non-normative. 47 + 48 + Given the numerous implementations in the wild predating this specification, servers and clients should strive for maximum compatibility. 49 + 50 + Servers may wish to support legacy clients that do not negotiate a subprotocol. For example, when no subprotocol is negotiated, servers could accept either incoming message type, while using a fixed (or operator-configurable) message type for outgoing messages. Alternatively, they could autodetect the outgoing message type based on the type of the first incoming message sent by the client. 51 + 52 + Similarly, clients may wish to support legacy servers that are unaware of subprotocols. The [standard JavaScript API](https://fetch.spec.whatwg.org/#websocket-opening-handshake) for WebSockets will [fail the WebSocket connection](https://tools.ietf.org/html/rfc6455#section-7.1.7) if none of the client's desired subprotocols could be negotiated. In this case, the client could attempt another connection without subprotocol negotiation. 53 + 54 + Some servers might support non-standard configurations where only one subprotocol is accepted. Clients can request both subprotocols to ensure compatibility with these server configurations.
+179
docs/superpowers/plans/2026-06-10-p11-slice53-websocket.md
··· 1 + # P11 slice 53 — WebSocket transport (axum) + `+W` umode + WHOIS `320` 2 + 3 + ## Goal 4 + 5 + Let browser/web IRC clients connect to leveva over WebSocket, per 6 + `docs/rfcs/ircv3/websocket.md` (IRCv3 `binary.ircv3.net` / `text.ircv3.net` 7 + subprotocols), served by **axum** at the HTTP path `/socket`. A WebSocket 8 + listener is a `listen{}` port carrying a nested `websocket{}` block. Clients 9 + that arrive over a WebSocket get a leveva-native `+W` user mode, and WHOIS shows 10 + a dedicated `320 RPL_WHOISEXTRA :is connected via WebSocket` line. 11 + 12 + ## Locked decisions (from the user) 13 + 14 + - Config: a **`listen{}` flag** — a nested `websocket{}` sub-block. Its presence 15 + enables WS on that port. Optional children: 16 + - `origins "<glob>" ...` — allowed `Origin:` header globs (glob-matched). 17 + - `proxy-ranges "<glob-or-CIDR>" ..." — peer IPs trusted to supply 18 + `X-Real-IP` / `X-Forwarded-For` (so a reverse proxy can pass the real client 19 + IP). Each entry is a glob **or** a CIDR. 20 + - `default-mode "text"|"binary"|"reject"` — frame mode when the client 21 + negotiates no subprotocol. Default `text`. 22 + - WHOIS numeric: **`320` RPL_WHOISEXTRA**, `:is connected via WebSocket`. 23 + - **axum holds the brunt** of the WS protocol (handshake/masking/framing/ping/ 24 + pong/close). We implement only the IRC-over-WS *semantics*. 25 + - TLS (`wss://`) reuses the existing per-listener `tls`/`tls-cert`/`tls-key` 26 + fields and the hot-reloadable `TlsEndpoint`. 27 + 28 + ## Reference behaviour (InspIRCd `m_websocket.cpp`, insp4) 29 + 30 + Faithful to the parts that define IRC-over-WS semantics (NOT the byte-level WS 31 + machinery — axum does that): 32 + 33 + - **Incoming** (ws.cpp:359-368): each complete frame (text or binary) is one IRC 34 + line; strip any embedded `\r`/`\n`, then terminate with `\r\n` before the 35 + protocol core sees it. 36 + - **Outgoing** (ws.cpp:588-637): split the core's wire bytes on `\n`; for each 37 + line drop a trailing `\r`; emit **one frame per line** — a **Text** frame 38 + (re-encoded so it is valid UTF-8: invalid bytes → `U+FFFD`, ws.cpp:608) when 39 + sending-as-text, else a **Binary** frame with the raw bytes. 40 + - **Subprotocol negotiation** (ws.cpp:506-534): scan `Sec-WebSocket-Protocol` 41 + for `binary.ircv3.net` / `text.ircv3.net` in client-preference order; first 42 + match wins and decides text-vs-binary. None matched + `default-mode=reject` → 43 + fail the handshake (400); else fall back to `default-mode`. 44 + - **Origin** (ws.cpp:434-463): glob-match the `Origin:` header against the 45 + allowed list; no list configured ⇒ allow any (we warn). Forbidden → 403. 46 + - **proxy-ranges** (ws.cpp:465-504): only if the peer IP matches a configured 47 + range do we trust `X-Real-IP` / `X-Forwarded-For` and use it as the client's 48 + display host. 49 + 50 + ## Where it plugs in (verified seams) 51 + 52 + - `leveva/src/main.rs` 53 + - `serve_client<S: AsyncRead+AsyncWrite>` (375) — **untouched**; TCP/TLS only. 54 + - `serve(Bound)` (296) accept loop — a WS listener instead runs the new 55 + `ws_server::serve_websocket(Bound, WebsocketConfig)`. 56 + - `Bound` (72) — boot wiring in `main()` (158-205) decides per-listener whether 57 + to spawn `serve` or `serve_websocket`. 58 + - `leveva/src/session.rs` 59 + - `Session` (36) gains a `websocket: bool` + `set_websocket`. 60 + - `finalize_registration` (404): seed `ctx.default_user_modes | +W` when 61 + `self.websocket`, into both `registry.set_modes` (533) and `Registered.modes` 62 + (556). `+W` thus mirrors to the registry → WHOIS sees it. 63 + - `leveva/src/mode.rs` — add `UserMode::Websocket` (`'W'`, bit `0x0040`). 64 + leveva-native: **no C oracle** (the C `user_modes[]` has no `W`); documented as 65 + an extension, appended last so render order of existing modes is unchanged. 66 + - `leveva/src/command/mode.rs:447` — add `'W'` to the silently-ignored 67 + server-only set (a client may not toggle `+W`; no `501`). 68 + - `leveva/src/command/whois.rs` — after the `313` block (~110), emit `320` when 69 + `rec.modes & UserMode::Websocket.bit() != 0`. 70 + - `leveva/src/config/{model,parse,mod}.rs` + `examples/simple.kdl` — 71 + `Listener.websocket: Option<WebsocketConfig>`; `WebsocketConfig`/`DefaultMode`. 72 + 73 + ## New code 74 + 75 + ### `leveva/src/websocket.rs` (LIB — pure, no axum) — `pub mod websocket;` 76 + 77 + Pure, fully unit/proptest-testable helpers: 78 + 79 + - `pub enum DefaultMode { Text, Binary, Reject }` (config `default-mode`). 80 + - `pub enum FrameMode { Text, Binary }` (actual outgoing frame type). 81 + - `pub enum Negotiation { Reject, Accept { subprotocol: Option<&'static str>, frame: FrameMode } }` 82 + - `pub fn negotiate(requested: &[&str], default: DefaultMode) -> Negotiation` — 83 + first `binary.ircv3.net`/`text.ircv3.net` in `requested` wins; else apply 84 + `default` (Reject → `Negotiation::Reject`). 85 + - `pub fn decode_incoming(payload: &[u8], out: &mut Vec<u8>)` — strip CR/LF, 86 + append `\r\n`. (One frame = one line.) 87 + - `pub fn frame_lines(mode: FrameMode, wire: &[u8]) -> Vec<OutFrame>` where 88 + `OutFrame { Text(String), Binary(Vec<u8>) }` — split `wire` on `\n`, drop a 89 + trailing `\r`, skip empty lines; Text → `String::from_utf8_lossy` (gives the 90 + `U+FFFD` replacement); Binary → raw bytes. 91 + - `pub fn origin_allowed(origins: &[String], origin: Option<&str>) -> bool` — 92 + empty list ⇒ true; else any glob match (`crate::matching::matches`). 93 + - `pub fn proxy_trusted(ranges: &[String], peer: IpAddr) -> bool` — glob match 94 + on the IP string OR CIDR containment (`cidr_contains`). 95 + - `fn cidr_contains(cidr: &str, ip: IpAddr) -> Option<bool>` — parse `base/len` 96 + for v4/v6 with std only (mask the high `len` bits); `None` if not CIDR syntax. 97 + 98 + ### `leveva/src/ws_server.rs` (BINARY module — axum/hyper glue) — `mod ws_server;` in main.rs 99 + 100 + - `pub async fn serve_websocket(b: Bound, cfg: WebsocketConfig)` — accept loop: 101 + per connection, optional TLS via `b.tls`, then serve ONE HTTP connection with 102 + `hyper_util::server::conn::auto::Builder::serve_connection_with_upgrades` 103 + (websockets need upgrades), the service being a per-connection 104 + `axum::Router` (peer baked into state) adapted by 105 + `hyper_util::service::TowerToHyperService`. 106 + - `GET /socket` → `ws_handler`: 107 + - origin check (`origin_allowed`) → 403 on fail; 108 + - `negotiate(requested, cfg.default_mode)` → 400 on `Reject`; 109 + - resolve display host (proxy-ranges + X-Real-IP/X-Forwarded-For, else peer IP); 110 + - `WebSocketUpgrade::protocols([chosen])` (echo the chosen subprotocol) then 111 + `.on_upgrade(move |socket| serve_ws(socket, host, ctx, ping_freq, auth_ctx, frame))`. 112 + - `async fn serve_ws(socket: WebSocket, host, ctx, ping_freq, auth_ctx, frame)` — 113 + the dedicated session loop (mirrors `serve_client`'s `select!` over socket / 114 + mailbox / keepalive / auth), but: 115 + - read: `socket.next()` → on Text/Binary `decode_incoming` → `session.feed`; 116 + Close/None/Err → break; Ping/Pong → ignore (tungstenite auto-pongs). 117 + - write: every `out`/mailbox `Wire` is `frame_lines(frame, &bytes)` then 118 + `socket.send(Message::Text|Binary).await` per frame (`send` flushes). 119 + - sets `session.set_websocket(true)` before the loop (→ `+W` at registration). 120 + Duplicating the orchestration is deliberate: the byte-stream `serve_client` 121 + stays untouched, and the per-line `SinkExt::send` gives correct flush semantics 122 + that a hand-rolled `AsyncWrite`-over-`Sink` adapter would not. 123 + 124 + ## Dependencies (leveva/Cargo.toml) 125 + 126 + - `axum = { version = "0.8", default-features = false, features = ["ws", "http1"] }` 127 + - `hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service", "http1"] }` 128 + - `futures-util` (for `StreamExt`/`SinkExt` on the WebSocket) — or use axum's 129 + `WebSocket` `recv`/`send` inherent methods to avoid the dep if sufficient. 130 + - dev: `tokio-tungstenite` (real WS client for the integration golden). 131 + 132 + ## Tests (TDD — failing test first each unit) 133 + 134 + 1. **`mode.rs`** unit: `+W` round-trips (char/bit/from_*), `render` appends `W` 135 + last and leaves existing order intact; bit is `0x0040`. 136 + 2. **`command/mode.rs`** unit: a client `MODE me +W` is a no-op (no `501`, `+W` 137 + not set); a `+W` seeded at registration shows in the `221` query string. 138 + 3. **`command/whois.rs`** unit: a `+W` target → codes `["311","312","320","318"]` 139 + with `320` trailing `is connected via WebSocket`; **inverse** non-WS → no 140 + `320`; ordering when also away+oper (`311 312 301 313 320 318`). 141 + 4. **`session.rs`** unit: a `set_websocket(true)` session registers with `+W` in 142 + its modes (and mirrored to the registry / visible to WHOIS `320`); **inverse** 143 + default session has no `+W`. 144 + 5. **`config`** unit (`config/tests.rs`): a `listen{ websocket{ origins ...; 145 + proxy-ranges ...; default-mode "binary" } }` parses to the expected 146 + `WebsocketConfig`; bare `websocket` enables with defaults (text, no origins); 147 + `websocket #false` / absent → `None`; bad `default-mode` → `BadField`; empty 148 + origins → a `warnings()` advisory. 149 + 6. **`websocket.rs`** unit: `decode_incoming` strips CR/LF + appends `\r\n`; 150 + `frame_lines` splits multi-line wire into per-line frames (text vs binary); 151 + `negotiate` preference order + reject; `origin_allowed`; `cidr_contains` v4/v6. 152 + 7. **Fuzz — `tests/websocket_proptest.rs`** (proptest): 153 + - **roundtrip**: for arbitrary line bytes (no `\n`), `decode_incoming` then 154 + `frame_lines(Binary, ..)` reproduces the original line (modulo CR/LF strip). 155 + - `frame_lines` never emits a frame containing `\n` or `\r`; text frames are 156 + always valid UTF-8. 157 + - `negotiate` never panics and only ever returns a subprotocol it was offered. 158 + - `origin_allowed`/`proxy_trusted` never panic on arbitrary input; `*` origin 159 + matches anything; a `/32`(`/128`) CIDR matches only its exact IP. 160 + 8. **Integration golden — `tests/golden_websocket.rs`** (real WS client via 161 + `tokio-tungstenite`): boot leveva with a `websocket{}` listener, connect to 162 + `ws://…/socket` requesting `text.ircv3.net`, send `NICK`/`USER` as frames, 163 + assert the `001` welcome arrives as a **text frame**, then `WHOIS` self shows 164 + `320 … :is connected via WebSocket` and `MODE` query shows `W`. Reuse the 165 + existing leveva boot harness used by `tests/integration_two_node.rs`. 166 + 167 + ## Gate 168 + 169 + `cargo build --workspace` 0 warnings; `cargo clippy -p leveva --tests` clean; 170 + all units + `websocket_proptest` + `golden_websocket` green; existing 171 + `whois_proptest` / `usermode_proptest` / config tests still green. 172 + 173 + ## Out of scope (note explicitly) 174 + 175 + - No native WS `PING` keepalive (`nativeping`) — leveva keeps IRC-level PING 176 + (the existing heartbeat); browser pings are auto-ponged by tungstenite. 177 + - No per-message-deflate, no legacy no-subprotocol autodetect beyond 178 + `default-mode`, no `allowmissingorigin` knob (empty origins already = allow). 179 + - Single `/socket` path only.